summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-05-20 14:34:42 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-05-20 14:34:42 +0000
commit9f46488805e86b1bc341ea1620b866016c2ce5ed (patch)
treef9748c7e287041e37d6da49e0a29c9511dc34768 /spec
parentdfc92d081ea0332d69c8aca2f0e745cb48ae5e6d (diff)
downloadgitlab-ce-9f46488805e86b1bc341ea1620b866016c2ce5ed.tar.gz
Add latest changes from gitlab-org/gitlab@13-0-stable-ee
Diffstat (limited to 'spec')
-rw-r--r--spec/channels/application_cable/connection_spec.rb47
-rw-r--r--spec/channels/issues_channel_spec.rb36
-rw-r--r--spec/config/application_spec.rb12
-rw-r--r--spec/config/mail_room_spec.rb3
-rw-r--r--spec/config/smime_signature_settings_spec.rb9
-rw-r--r--spec/controllers/admin/ci/variables_controller_spec.rb70
-rw-r--r--spec/controllers/admin/clusters_controller_spec.rb25
-rw-r--r--spec/controllers/admin/requests_profiles_controller_spec.rb6
-rw-r--r--spec/controllers/admin/users_controller_spec.rb2
-rw-r--r--spec/controllers/application_controller_spec.rb73
-rw-r--r--spec/controllers/concerns/issuable_actions_spec.rb2
-rw-r--r--spec/controllers/concerns/metrics_dashboard_spec.rb33
-rw-r--r--spec/controllers/dashboard/projects_controller_spec.rb51
-rw-r--r--spec/controllers/google_api/authorizations_controller_spec.rb26
-rw-r--r--spec/controllers/graphql_controller_spec.rb65
-rw-r--r--spec/controllers/groups/clusters_controller_spec.rb25
-rw-r--r--spec/controllers/groups/group_links_controller_spec.rb36
-rw-r--r--spec/controllers/groups/registry/repositories_controller_spec.rb30
-rw-r--r--spec/controllers/groups/settings/integrations_controller_spec.rb4
-rw-r--r--spec/controllers/groups/settings/repository_controller_spec.rb4
-rw-r--r--spec/controllers/groups_controller_spec.rb59
-rw-r--r--spec/controllers/help_controller_spec.rb7
-rw-r--r--spec/controllers/import/bitbucket_controller_spec.rb2
-rw-r--r--spec/controllers/ldap/omniauth_callbacks_controller_spec.rb51
-rw-r--r--spec/controllers/oauth/token_info_controller_spec.rb10
-rw-r--r--spec/controllers/omniauth_callbacks_controller_spec.rb11
-rw-r--r--spec/controllers/profiles/emails_controller_spec.rb24
-rw-r--r--spec/controllers/projects/alert_management_controller_spec.rb59
-rw-r--r--spec/controllers/projects/artifacts_controller_spec.rb42
-rw-r--r--spec/controllers/projects/branches_controller_spec.rb56
-rw-r--r--spec/controllers/projects/ci/daily_build_group_report_results_controller_spec.rb80
-rw-r--r--spec/controllers/projects/clusters_controller_spec.rb27
-rw-r--r--spec/controllers/projects/cycle_analytics/events_controller_spec.rb6
-rw-r--r--spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb153
-rw-r--r--spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb148
-rw-r--r--spec/controllers/projects/environments/prometheus_api_controller_spec.rb4
-rw-r--r--spec/controllers/projects/environments_controller_spec.rb36
-rw-r--r--spec/controllers/projects/grafana_api_controller_spec.rb3
-rw-r--r--spec/controllers/projects/graphs_controller_spec.rb20
-rw-r--r--spec/controllers/projects/import/jira_controller_spec.rb15
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb281
-rw-r--r--spec/controllers/projects/logs_controller_spec.rb111
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb307
-rw-r--r--spec/controllers/projects/mirrors_controller_spec.rb2
-rw-r--r--spec/controllers/projects/pipelines_controller_spec.rb119
-rw-r--r--spec/controllers/projects/prometheus/alerts_controller_spec.rb2
-rw-r--r--spec/controllers/projects/refs_controller_spec.rb34
-rw-r--r--spec/controllers/projects/registry/repositories_controller_spec.rb28
-rw-r--r--spec/controllers/projects/service_hook_logs_controller_spec.rb2
-rw-r--r--spec/controllers/projects/settings/access_tokens_controller_spec.rb190
-rw-r--r--spec/controllers/projects/settings/repository_controller_spec.rb4
-rw-r--r--spec/controllers/projects/snippets_controller_spec.rb38
-rw-r--r--spec/controllers/projects/static_site_editor_controller_spec.rb14
-rw-r--r--spec/controllers/projects/usage_ping_controller_spec.rb68
-rw-r--r--spec/controllers/projects/wikis_controller_spec.rb26
-rw-r--r--spec/controllers/projects_controller_spec.rb26
-rw-r--r--spec/controllers/registrations_controller_spec.rb32
-rw-r--r--spec/controllers/repositories/git_http_controller_spec.rb47
-rw-r--r--spec/controllers/search_controller_spec.rb10
-rw-r--r--spec/controllers/sessions_controller_spec.rb13
-rw-r--r--spec/controllers/snippets_controller_spec.rb90
-rw-r--r--spec/db/schema_spec.rb18
-rw-r--r--spec/factories/alert_management/alerts.rb81
-rw-r--r--spec/factories/appearances.rb1
-rw-r--r--spec/factories/ci/builds.rb18
-rw-r--r--spec/factories/ci/daily_build_group_report_results.rb14
-rw-r--r--spec/factories/ci/daily_report_results.rb13
-rw-r--r--spec/factories/ci/freeze_periods.rb10
-rw-r--r--spec/factories/ci/instance_variables.rb13
-rw-r--r--spec/factories/ci/job_artifacts.rb74
-rw-r--r--spec/factories/ci/pipelines.rb24
-rw-r--r--spec/factories/ci/test_case.rb2
-rw-r--r--spec/factories/clusters/applications/helm.rb24
-rw-r--r--spec/factories/deploy_tokens.rb8
-rw-r--r--spec/factories/design_management/actions.rb13
-rw-r--r--spec/factories/design_management/design_at_version.rb23
-rw-r--r--spec/factories/design_management/designs.rb128
-rw-r--r--spec/factories/design_management/versions.rb142
-rw-r--r--spec/factories/events.rb14
-rw-r--r--spec/factories/git_wiki_commit_details.rb15
-rw-r--r--spec/factories/groups.rb10
-rw-r--r--spec/factories/identities.rb2
-rw-r--r--spec/factories/iterations.rb60
-rw-r--r--spec/factories/merge_requests.rb24
-rw-r--r--spec/factories/metrics/users_starred_dasboards.rb9
-rw-r--r--spec/factories/notes.rb19
-rw-r--r--spec/factories/plan_limits.rb11
-rw-r--r--spec/factories/plans.rb13
-rw-r--r--spec/factories/project_repository_storage_moves.rb14
-rw-r--r--spec/factories/project_wikis.rb11
-rw-r--r--spec/factories/projects.rb6
-rw-r--r--spec/factories/remote_mirrors.rb5
-rw-r--r--spec/factories/resource_state_event.rb10
-rw-r--r--spec/factories/sequences.rb1
-rw-r--r--spec/factories/services.rb7
-rw-r--r--spec/factories/uploads.rb6
-rw-r--r--spec/factories/usage_data.rb26
-rw-r--r--spec/factories/users.rb6
-rw-r--r--spec/factories/wiki_pages.rb44
-rw-r--r--spec/factories/wikis.rb21
-rw-r--r--spec/features/admin/admin_appearance_spec.rb18
-rw-r--r--spec/features/admin/admin_browses_logs_spec.rb20
-rw-r--r--spec/features/admin/admin_hooks_spec.rb18
-rw-r--r--spec/features/admin/admin_mode/login_spec.rb77
-rw-r--r--spec/features/admin/admin_settings_spec.rb40
-rw-r--r--spec/features/admin/admin_users_impersonation_tokens_spec.rb4
-rw-r--r--spec/features/boards/boards_spec.rb54
-rw-r--r--spec/features/boards/focus_mode_spec.rb17
-rw-r--r--spec/features/boards/sidebar_spec.rb2
-rw-r--r--spec/features/commits/user_view_commits_spec.rb22
-rw-r--r--spec/features/dashboard/help_spec.rb21
-rw-r--r--spec/features/dashboard/issues_spec.rb2
-rw-r--r--spec/features/dashboard/snippets_spec.rb43
-rw-r--r--spec/features/dashboard/todos/todos_spec.rb42
-rw-r--r--spec/features/error_tracking/user_filters_errors_by_status_spec.rb2
-rw-r--r--spec/features/error_tracking/user_sees_error_index_spec.rb2
-rw-r--r--spec/features/explore/groups_spec.rb2
-rw-r--r--spec/features/global_search_spec.rb2
-rw-r--r--spec/features/groups/import_export/export_file_spec.rb59
-rw-r--r--spec/features/groups/issues_spec.rb23
-rw-r--r--spec/features/groups/members/leave_group_spec.rb2
-rw-r--r--spec/features/groups/members/manage_groups_spec.rb85
-rw-r--r--spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb68
-rw-r--r--spec/features/groups/navbar_spec.rb52
-rw-r--r--spec/features/groups_spec.rb36
-rw-r--r--spec/features/help_pages_spec.rb32
-rw-r--r--spec/features/issues/bulk_assignment_labels_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/filter_issues_spec.rb47
-rw-r--r--spec/features/issues/filtered_search/visual_tokens_spec.rb18
-rw-r--r--spec/features/issues/spam_issues_spec.rb121
-rw-r--r--spec/features/issues/update_issues_spec.rb2
-rw-r--r--spec/features/issues/user_creates_branch_and_merge_request_spec.rb12
-rw-r--r--spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb32
-rw-r--r--spec/features/markdown/copy_as_gfm_spec.rb6
-rw-r--r--spec/features/markdown/metrics_spec.rb2
-rw-r--r--spec/features/merge_request/maintainer_edits_fork_spec.rb2
-rw-r--r--spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb2
-rw-r--r--spec/features/merge_request/user_posts_diff_notes_spec.rb4
-rw-r--r--spec/features/merge_request/user_posts_notes_spec.rb5
-rw-r--r--spec/features/merge_request/user_resolves_conflicts_spec.rb4
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb54
-rw-r--r--spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb1
-rw-r--r--spec/features/merge_requests/user_mass_updates_spec.rb2
-rw-r--r--spec/features/milestones/user_creates_milestone_spec.rb6
-rw-r--r--spec/features/milestones/user_views_milestone_spec.rb6
-rw-r--r--spec/features/profiles/emails_spec.rb11
-rw-r--r--spec/features/profiles/personal_access_tokens_spec.rb4
-rw-r--r--spec/features/projects/activity/user_sees_design_comment_spec.rb51
-rw-r--r--spec/features/projects/branches/user_creates_branch_spec.rb10
-rw-r--r--spec/features/projects/commit/comments/user_edits_comments_spec.rb6
-rw-r--r--spec/features/projects/environments_pod_logs_spec.rb2
-rw-r--r--spec/features/projects/files/user_browses_files_spec.rb39
-rw-r--r--spec/features/projects/graph_spec.rb2
-rw-r--r--spec/features/projects/import_export/export_file_spec.rb4
-rw-r--r--spec/features/projects/issues/design_management/user_paginates_designs_spec.rb40
-rw-r--r--spec/features/projects/issues/design_management/user_permissions_upload_spec.rb24
-rw-r--r--spec/features/projects/issues/design_management/user_uploads_designs_spec.rb63
-rw-r--r--spec/features/projects/issues/design_management/user_views_design_images_spec.rb41
-rw-r--r--spec/features/projects/issues/design_management/user_views_design_spec.rb29
-rw-r--r--spec/features/projects/issues/design_management/user_views_designs_spec.rb47
-rw-r--r--spec/features/projects/issues/design_management/user_views_designs_with_svg_xss_spec.rb55
-rw-r--r--spec/features/projects/members/list_spec.rb19
-rw-r--r--spec/features/projects/navbar_spec.rb21
-rw-r--r--spec/features/projects/pipelines/pipeline_spec.rb72
-rw-r--r--spec/features/projects/serverless/functions_spec.rb2
-rw-r--r--spec/features/projects/services/disable_triggers_spec.rb10
-rw-r--r--spec/features/projects/services/prometheus_external_alerts_spec.rb20
-rw-r--r--spec/features/projects/services/user_activates_issue_tracker_spec.rb59
-rw-r--r--spec/features/projects/services/user_activates_jira_spec.rb42
-rw-r--r--spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb204
-rw-r--r--spec/features/projects/services/user_activates_slack_slash_command_spec.rb9
-rw-r--r--spec/features/projects/services/user_activates_youtrack_spec.rb91
-rw-r--r--spec/features/projects/settings/access_tokens_spec.rb93
-rw-r--r--spec/features/projects/settings/operations_settings_spec.rb2
-rw-r--r--spec/features/projects/settings/project_settings_spec.rb30
-rw-r--r--spec/features/projects/settings/registry_settings_spec.rb2
-rw-r--r--spec/features/projects/settings/repository_settings_spec.rb16
-rw-r--r--spec/features/projects/settings/user_interacts_with_deploy_keys_spec.rb10
-rw-r--r--spec/features/projects/settings/user_sees_revoke_deploy_token_modal_spec.rb2
-rw-r--r--spec/features/projects/snippets/create_snippet_spec.rb17
-rw-r--r--spec/features/projects/snippets/user_updates_snippet_spec.rb20
-rw-r--r--spec/features/projects/user_sees_user_popover_spec.rb18
-rw-r--r--spec/features/projects/wiki/markdown_preview_spec.rb2
-rw-r--r--spec/features/projects/wiki/shortcuts_spec.rb2
-rw-r--r--spec/features/projects/wiki/user_creates_wiki_page_spec.rb35
-rw-r--r--spec/features/projects/wiki/user_git_access_wiki_page_spec.rb2
-rw-r--r--spec/features/projects/wiki/user_updates_wiki_page_spec.rb4
-rw-r--r--spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb2
-rw-r--r--spec/features/projects/wiki/user_views_wiki_page_spec.rb2
-rw-r--r--spec/features/projects/wiki/user_views_wiki_pages_spec.rb6
-rw-r--r--spec/features/projects/wiki/users_views_asciidoc_page_with_includes_spec.rb2
-rw-r--r--spec/features/search/user_searches_for_code_spec.rb4
-rw-r--r--spec/features/search/user_searches_for_wiki_pages_spec.rb2
-rw-r--r--spec/features/security/project/internal_access_spec.rb6
-rw-r--r--spec/features/security/project/private_access_spec.rb2
-rw-r--r--spec/features/security/project/public_access_spec.rb10
-rw-r--r--spec/features/snippets/search_snippets_spec.rb2
-rw-r--r--spec/features/snippets/spam_snippets_spec.rb76
-rw-r--r--spec/features/snippets/user_creates_snippet_spec.rb19
-rw-r--r--spec/features/snippets/user_edits_snippet_spec.rb20
-rw-r--r--spec/features/static_site_editor_spec.rb4
-rw-r--r--spec/features/users/signup_spec.rb4
-rw-r--r--spec/finders/alert_management/alerts_finder_spec.rb298
-rw-r--r--spec/finders/artifacts_finder_spec.rb31
-rw-r--r--spec/finders/ci/daily_build_group_report_results_finder_spec.rb72
-rw-r--r--spec/finders/ci/job_artifacts_finder_spec.rb31
-rw-r--r--spec/finders/container_repositories_finder_spec.rb29
-rw-r--r--spec/finders/design_management/designs_finder_spec.rb105
-rw-r--r--spec/finders/design_management/versions_finder_spec.rb129
-rw-r--r--spec/finders/fork_projects_finder_spec.rb2
-rw-r--r--spec/finders/freeze_periods_finder_spec.rb59
-rw-r--r--spec/finders/issues_finder_spec.rb50
-rw-r--r--spec/finders/members_finder_spec.rb4
-rw-r--r--spec/finders/merge_requests_finder_spec.rb11
-rw-r--r--spec/finders/metrics/users_starred_dashboards_finder_spec.rb55
-rw-r--r--spec/finders/projects/serverless/functions_finder_spec.rb1
-rw-r--r--spec/finders/releases_finder_spec.rb11
-rw-r--r--spec/finders/todos_finder_spec.rb4
-rw-r--r--spec/fixtures/accessibility/pa11y_with_errors.json109
-rw-r--r--spec/fixtures/accessibility/pa11y_with_invalid_url.json12
-rw-r--r--spec/fixtures/accessibility/pa11y_without_errors.json8
-rw-r--r--spec/fixtures/api/schemas/cluster_list.json14
-rw-r--r--spec/fixtures/api/schemas/cluster_status.json2
-rw-r--r--spec/fixtures/api/schemas/entities/accessibility_error.json40
-rw-r--r--spec/fixtures/api/schemas/entities/accessibility_reports_comparer.json43
-rw-r--r--spec/fixtures/api/schemas/entities/discussion.json11
-rw-r--r--spec/fixtures/api/schemas/entities/note_user_entity.json3
-rw-r--r--spec/fixtures/api/schemas/entities/user.json3
-rw-r--r--spec/fixtures/api/schemas/pipeline_schedule.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/branch.json6
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/freeze_period.json20
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/freeze_periods.json5
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/issue.json9
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/members.json5
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/notes.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/project_repository_storage_move.json20
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/project_repository_storage_moves.json6
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/snippets.json7
-rw-r--r--spec/fixtures/config/mail_room_enabled.yml2
-rw-r--r--spec/fixtures/config/redis_cache_new_format_host.yml8
-rw-r--r--spec/fixtures/config/redis_new_format_host.yml8
-rw-r--r--spec/fixtures/config/redis_queues_new_format_host.yml8
-rw-r--r--spec/fixtures/config/redis_shared_state_new_format_host.yml8
-rw-r--r--spec/fixtures/group_export.tar.gzbin3546 -> 2921 bytes
-rw-r--r--spec/fixtures/group_export_invalid_subrelations.tar.gzbin3602 -> 2868 bytes
-rw-r--r--spec/fixtures/helm/helm_list_v2_prometheus_deployed.json.gzbin0 -> 338 bytes
-rw-r--r--spec/fixtures/helm/helm_list_v2_prometheus_failed.json.gzbin0 -> 339 bytes
-rw-r--r--spec/fixtures/helm/helm_list_v2_prometheus_missing.json.gzbin0 -> 320 bytes
-rw-r--r--spec/fixtures/legacy_group_export.tar.gzbin0 -> 3546 bytes
-rw-r--r--spec/fixtures/legacy_group_export_invalid_subrelations.tar.gzbin0 -> 3602 bytes
-rw-r--r--spec/fixtures/legacy_symlink_export.tar.gzbin0 -> 435 bytes
-rw-r--r--spec/fixtures/lib/elasticsearch/query.json2
-rw-r--r--spec/fixtures/lib/elasticsearch/query_with_container.json2
-rw-r--r--spec/fixtures/lib/elasticsearch/query_with_cursor.json2
-rw-r--r--spec/fixtures/lib/elasticsearch/query_with_end_time.json2
-rw-r--r--spec/fixtures/lib/elasticsearch/query_with_filebeat_6.json40
-rw-r--r--spec/fixtures/lib/elasticsearch/query_with_search.json2
-rw-r--r--spec/fixtures/lib/elasticsearch/query_with_start_time.json2
-rw-r--r--spec/fixtures/lib/elasticsearch/query_with_times.json2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/project.json40
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree.tar.gzbin32595 -> 0 bytes
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/auto_devops.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/boards.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/ci_cd_settings.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/ci_pipelines.ndjson7
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/container_expiration_policy.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/custom_attributes.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/error_tracking_setting.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/external_pull_requests.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/issues.ndjson10
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/labels.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/merge_requests.ndjson9
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/milestones.ndjson3
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/pipeline_schedules.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/project_badges.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/project_feature.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/project_members.ndjson4
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_branches.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_environments.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_tags.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/releases.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/services.ndjson19
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/snippets.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/triggers.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/designs/project.json502
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group/tree.tar.gzbin1246 -> 0 bytes
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group/tree/project.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group/tree/project/issues.ndjson3
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group/tree/project/labels.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group/tree/project/milestones.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/child_with_no_parent/tree/groups/4351.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/child_with_no_parent/tree/groups/4352.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/child_with_no_parent/tree/groups/_all.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/badges.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/boards.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/epics.ndjson5
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/labels.ndjson10
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/members.ndjson6
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/milestones.ndjson5
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/badges.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/boards.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/epics.ndjson5
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/labels.ndjson9
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/members.ndjson6
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/milestones.ndjson5
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/badges.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/boards.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/epics.ndjson5
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/labels.ndjson9
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/members.ndjson6
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/milestones.ndjson5
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/_all.ndjson3
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353.json41
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/badges.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/boards.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/epics.ndjson5
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/labels.ndjson10
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/members.ndjson6
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/milestones.ndjson5
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/_all.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/283.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/284.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/285.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/286.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/_all.ndjson4
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/283.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/284.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/285.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/286.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/_all.ndjson4
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/283.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/284.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/285.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/286.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/_all.ndjson4
-rw-r--r--spec/fixtures/lib/gitlab/import_export/invalid_json/tree.tar.gzbin191 -> 0 bytes
-rw-r--r--spec/fixtures/lib/gitlab/import_export/invalid_json/tree/project.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/light/tree.tar.gzbin1435 -> 0 bytes
-rw-r--r--spec/fixtures/lib/gitlab/import_export/light/tree/project.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/light/tree/project/custom_attributes.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/light/tree/project/issues.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/light/tree/project/labels.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/light/tree/project/milestones.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/light/tree/project/services.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/milestone-iid/tree.tar.gzbin714 -> 0 bytes
-rw-r--r--spec/fixtures/lib/gitlab/import_export/milestone-iid/tree/project.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/milestone-iid/tree/project/issues.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree.tar.gzbin1172 -> 0 bytes
-rw-r--r--spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/ci_cd_settings.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/ci_pipelines.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/external_pull_requests.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/project_feature.ndjson1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/with_invalid_records/tree.tar.gzbin513 -> 0 bytes
-rw-r--r--spec/fixtures/lib/gitlab/import_export/with_invalid_records/tree/project.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/with_invalid_records/tree/project/milestones.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/metrics/dashboard/development_metrics.yml39
-rw-r--r--spec/fixtures/lsif.json.zipbin0 -> 2178 bytes
-rw-r--r--spec/fixtures/sample_doc.md1
-rw-r--r--spec/fixtures/terraform/tfplan.json1
-rw-r--r--spec/fixtures/terraform/tfplan_with_corrupted_data.json1
-rw-r--r--spec/fixtures/trace/sample_trace4
-rw-r--r--spec/fixtures/x509/ZZZZZZA6.crlbin0 -> 205280 bytes
-rw-r--r--spec/frontend/.eslintrc.yml17
-rw-r--r--spec/frontend/__mocks__/@toast-ui/vue-editor/index.js29
-rw-r--r--spec/frontend/ajax_loading_spinner_spec.js57
-rw-r--r--spec/frontend/alert_management/components/alert_management_detail_spec.js242
-rw-r--r--spec/frontend/alert_management/components/alert_management_list_spec.js325
-rw-r--r--spec/frontend/alert_management/mocks/alerts.json29
-rw-r--r--spec/frontend/api_spec.js2
-rw-r--r--spec/frontend/autosave_spec.js90
-rw-r--r--spec/frontend/avatar_helper_spec.js110
-rw-r--r--spec/frontend/behaviors/markdown/paste_markdown_table_spec.js12
-rw-r--r--spec/frontend/behaviors/markdown/render_metrics_spec.js36
-rw-r--r--spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap2
-rw-r--r--spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap11
-rw-r--r--spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap2
-rw-r--r--spec/frontend/blob/components/blob_content_error_spec.js51
-rw-r--r--spec/frontend/blob/components/blob_content_spec.js36
-rw-r--r--spec/frontend/blob/components/blob_header_filepath_spec.js10
-rw-r--r--spec/frontend/blob/components/blob_header_spec.js2
-rw-r--r--spec/frontend/blob/components/mock_data.js2
-rw-r--r--spec/frontend/blob/pipeline_tour_success_modal_spec.js2
-rw-r--r--spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js9
-rw-r--r--spec/frontend/blob/utils_spec.js42
-rw-r--r--spec/frontend/boards/board_list_spec.js2
-rw-r--r--spec/frontend/boards/boards_store_spec.js137
-rw-r--r--spec/frontend/boards/issue_spec.js22
-rw-r--r--spec/frontend/bootstrap_linked_tabs_spec.js67
-rw-r--r--spec/frontend/broadcast_notification_spec.js35
-rw-r--r--spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js203
-rw-r--r--spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js282
-rw-r--r--spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js (renamed from spec/javascripts/ci_variable_list/native_form_variable_list_spec.js)0
-rw-r--r--spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js7
-rw-r--r--spec/frontend/ci_variable_list/services/mock_data.js12
-rw-r--r--spec/frontend/ci_variable_list/store/actions_spec.js10
-rw-r--r--spec/frontend/ci_variable_list/store/mutations_spec.js10
-rw-r--r--spec/frontend/close_reopen_report_toggle_spec.js288
-rw-r--r--spec/frontend/clusters/components/applications_spec.js33
-rw-r--r--spec/frontend/clusters/components/fluentd_output_settings_spec.js186
-rw-r--r--spec/frontend/clusters/components/knative_domain_editor_spec.js2
-rw-r--r--spec/frontend/clusters/services/mock_data.js1
-rw-r--r--spec/frontend/clusters/stores/clusters_store_spec.js18
-rw-r--r--spec/frontend/clusters_list/components/clusters_spec.js110
-rw-r--r--spec/frontend/clusters_list/mock_data.js18
-rw-r--r--spec/frontend/clusters_list/store/actions_spec.js29
-rw-r--r--spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap11
-rw-r--r--spec/frontend/code_navigation/components/app_spec.js1
-rw-r--r--spec/frontend/code_navigation/components/popover_spec.js41
-rw-r--r--spec/frontend/code_navigation/store/actions_spec.js28
-rw-r--r--spec/frontend/commit/pipelines/pipelines_spec.js44
-rw-r--r--spec/frontend/commit_merge_requests_spec.js (renamed from spec/javascripts/commit_merge_requests_spec.js)0
-rw-r--r--spec/frontend/commits_spec.js98
-rw-r--r--spec/frontend/contributors/store/actions_spec.js3
-rw-r--r--spec/frontend/contributors/store/getters_spec.js3
-rw-r--r--spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js14
-rw-r--r--spec/frontend/create_item_dropdown_spec.js (renamed from spec/javascripts/create_item_dropdown_spec.js)0
-rw-r--r--spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js1
-rw-r--r--spec/frontend/deploy_keys/components/action_btn_spec.js54
-rw-r--r--spec/frontend/deploy_keys/components/app_spec.js142
-rw-r--r--spec/frontend/deploy_keys/components/key_spec.js161
-rw-r--r--spec/frontend/deploy_keys/components/keys_panel_spec.js63
-rw-r--r--spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap42
-rw-r--r--spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap104
-rw-r--r--spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap115
-rw-r--r--spec/frontend/design_management/components/__snapshots__/image_spec.js.snap68
-rw-r--r--spec/frontend/design_management/components/delete_button_spec.js51
-rw-r--r--spec/frontend/design_management/components/design_note_pin_spec.js49
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap61
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap15
-rw-r--r--spec/frontend/design_management/components/design_notes/design_discussion_spec.js133
-rw-r--r--spec/frontend/design_management/components/design_notes/design_note_spec.js170
-rw-r--r--spec/frontend/design_management/components/design_notes/design_reply_form_spec.js182
-rw-r--r--spec/frontend/design_management/components/design_overlay_spec.js393
-rw-r--r--spec/frontend/design_management/components/design_presentation_spec.js546
-rw-r--r--spec/frontend/design_management/components/design_scaler_spec.js67
-rw-r--r--spec/frontend/design_management/components/image_spec.js133
-rw-r--r--spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap472
-rw-r--r--spec/frontend/design_management/components/list/item_spec.js168
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap61
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap28
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap29
-rw-r--r--spec/frontend/design_management/components/toolbar/index_spec.js123
-rw-r--r--spec/frontend/design_management/components/toolbar/pagination_button_spec.js61
-rw-r--r--spec/frontend/design_management/components/toolbar/pagination_spec.js79
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap79
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap455
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap111
-rw-r--r--spec/frontend/design_management/components/upload/button_spec.js59
-rw-r--r--spec/frontend/design_management/components/upload/design_dropzone_spec.js132
-rw-r--r--spec/frontend/design_management/components/upload/design_version_dropdown_spec.js114
-rw-r--r--spec/frontend/design_management/components/upload/mock_data/all_versions.js14
-rw-r--r--spec/frontend/design_management/mock_data/all_versions.js8
-rw-r--r--spec/frontend/design_management/mock_data/design.js54
-rw-r--r--spec/frontend/design_management/mock_data/designs.js17
-rw-r--r--spec/frontend/design_management/mock_data/no_designs.js11
-rw-r--r--spec/frontend/design_management/mock_data/notes.js32
-rw-r--r--spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap263
-rw-r--r--spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap184
-rw-r--r--spec/frontend/design_management/pages/design/index_spec.js301
-rw-r--r--spec/frontend/design_management/pages/index_spec.js533
-rw-r--r--spec/frontend/design_management/router_spec.js81
-rw-r--r--spec/frontend/design_management/utils/cache_update_spec.js44
-rw-r--r--spec/frontend/design_management/utils/design_management_utils_spec.js176
-rw-r--r--spec/frontend/design_management/utils/error_messages_spec.js62
-rw-r--r--spec/frontend/design_management/utils/tracking_spec.js53
-rw-r--r--spec/frontend/diff_comments_store_spec.js136
-rw-r--r--spec/frontend/diffs/components/app_spec.js212
-rw-r--r--spec/frontend/diffs/components/commit_item_spec.js144
-rw-r--r--spec/frontend/diffs/components/diff_content_spec.js2
-rw-r--r--spec/frontend/diffs/components/diff_discussions_spec.js2
-rw-r--r--spec/frontend/diffs/components/diff_expansion_cell_spec.js2
-rw-r--r--spec/frontend/diffs/components/diff_gutter_avatars_spec.js2
-rw-r--r--spec/frontend/diffs/components/diff_line_note_form_spec.js2
-rw-r--r--spec/frontend/diffs/components/edit_button_spec.js19
-rw-r--r--spec/frontend/diffs/components/inline_diff_expansion_row_spec.js2
-rw-r--r--spec/frontend/diffs/components/inline_diff_view_spec.js4
-rw-r--r--spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js2
-rw-r--r--spec/frontend/diffs/components/parallel_diff_view_spec.js2
-rw-r--r--spec/frontend/diffs/store/actions_spec.js184
-rw-r--r--spec/frontend/diffs/store/getters_spec.js4
-rw-r--r--spec/frontend/diffs/store/getters_versions_dropdowns_spec.js9
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js30
-rw-r--r--spec/frontend/diffs/store/utils_spec.js84
-rw-r--r--spec/frontend/dirty_submit/dirty_submit_collection_spec.js22
-rw-r--r--spec/frontend/dirty_submit/dirty_submit_factory_spec.js (renamed from spec/javascripts/dirty_submit/dirty_submit_factory_spec.js)0
-rw-r--r--spec/frontend/dirty_submit/dirty_submit_form_spec.js97
-rw-r--r--spec/frontend/dirty_submit/helper.js43
-rw-r--r--spec/frontend/editor/editor_lite_spec.js177
-rw-r--r--spec/frontend/emoji_spec.js485
-rw-r--r--spec/frontend/feature_highlight/feature_highlight_helper_spec.js62
-rw-r--r--spec/frontend/feature_highlight/feature_highlight_options_spec.js44
-rw-r--r--spec/frontend/feature_highlight/feature_highlight_spec.js120
-rw-r--r--spec/frontend/filtered_search/dropdown_utils_spec.js374
-rw-r--r--spec/frontend/filtered_search/filtered_search_manager_spec.js587
-rw-r--r--spec/frontend/filtered_search/filtered_search_tokenizer_spec.js (renamed from spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js)0
-rw-r--r--spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js (renamed from spec/javascripts/filtered_search/issues_filtered_search_token_keys_spec.js)0
-rw-r--r--spec/frontend/filtered_search/recent_searches_root_spec.js32
-rw-r--r--spec/frontend/filtered_search/services/recent_searches_service_spec.js161
-rw-r--r--spec/frontend/filtered_search/visual_token_value_spec.js389
-rw-r--r--spec/frontend/fixtures/test_report.rb2
-rw-r--r--spec/frontend/flash_spec.js233
-rw-r--r--spec/frontend/frequent_items/components/app_spec.js251
-rw-r--r--spec/frontend/frequent_items/mock_data.js127
-rw-r--r--spec/frontend/frequent_items/store/actions_spec.js228
-rw-r--r--spec/frontend/frequent_items/store/mutations_spec.js (renamed from spec/javascripts/frequent_items/store/mutations_spec.js)0
-rw-r--r--spec/frontend/frequent_items/utils_spec.js130
-rw-r--r--spec/frontend/groups/components/app_spec.js507
-rw-r--r--spec/frontend/groups/components/group_folder_spec.js65
-rw-r--r--spec/frontend/groups/components/group_item_spec.js215
-rw-r--r--spec/frontend/groups/components/groups_spec.js72
-rw-r--r--spec/frontend/groups/components/item_actions_spec.js84
-rw-r--r--spec/frontend/groups/components/item_caret_spec.js38
-rw-r--r--spec/frontend/groups/components/item_stats_spec.js119
-rw-r--r--spec/frontend/groups/components/item_stats_value_spec.js82
-rw-r--r--spec/frontend/groups/components/item_type_icon_spec.js53
-rw-r--r--spec/frontend/groups/mock_data.js (renamed from spec/javascripts/groups/mock_data.js)0
-rw-r--r--spec/frontend/groups/service/groups_service_spec.js42
-rw-r--r--spec/frontend/groups/store/groups_store_spec.js123
-rw-r--r--spec/frontend/header_spec.js16
-rw-r--r--spec/frontend/helpers/class_spec_helper.js1
-rw-r--r--spec/frontend/helpers/event_hub_factory_spec.js94
-rw-r--r--spec/frontend/helpers/filtered_search_spec_helper.js69
-rw-r--r--spec/frontend/helpers/fixtures.js5
-rw-r--r--spec/frontend/helpers/set_window_location_helper.js40
-rw-r--r--spec/frontend/helpers/set_window_location_helper_spec.js40
-rw-r--r--spec/frontend/helpers/vue_mount_component_helper.js25
-rw-r--r--spec/frontend/helpers/web_worker_mock.js10
-rw-r--r--spec/frontend/ide/components/activity_bar_spec.js72
-rw-r--r--spec/frontend/ide/components/commit_sidebar/editor_header_spec.js50
-rw-r--r--spec/frontend/ide/components/commit_sidebar/form_spec.js111
-rw-r--r--spec/frontend/ide/components/commit_sidebar/list_spec.js2
-rw-r--r--spec/frontend/ide/components/commit_sidebar/radio_group_spec.js134
-rw-r--r--spec/frontend/ide/components/file_row_extra_spec.js170
-rw-r--r--spec/frontend/ide/components/file_templates/bar_spec.js117
-rw-r--r--spec/frontend/ide/components/ide_review_spec.js73
-rw-r--r--spec/frontend/ide/components/ide_side_bar_spec.js57
-rw-r--r--spec/frontend/ide/components/ide_spec.js125
-rw-r--r--spec/frontend/ide/components/ide_status_bar_spec.js127
-rw-r--r--spec/frontend/ide/components/ide_tree_list_spec.js77
-rw-r--r--spec/frontend/ide/components/ide_tree_spec.js34
-rw-r--r--spec/frontend/ide/components/jobs/detail/description_spec.js (renamed from spec/javascripts/ide/components/jobs/detail/description_spec.js)0
-rw-r--r--spec/frontend/ide/components/jobs/item_spec.js (renamed from spec/javascripts/ide/components/jobs/item_spec.js)0
-rw-r--r--spec/frontend/ide/components/merge_requests/item_spec.js63
-rw-r--r--spec/frontend/ide/components/nav_dropdown_button_spec.js93
-rw-r--r--spec/frontend/ide/components/nav_dropdown_spec.js102
-rw-r--r--spec/frontend/ide/components/new_dropdown/button_spec.js65
-rw-r--r--spec/frontend/ide/components/new_dropdown/index_spec.js84
-rw-r--r--spec/frontend/ide/components/new_dropdown/modal_spec.js175
-rw-r--r--spec/frontend/ide/components/new_dropdown/upload_spec.js112
-rw-r--r--spec/frontend/ide/components/pipelines/list_spec.js17
-rw-r--r--spec/frontend/ide/components/preview/clientside_spec.js8
-rw-r--r--spec/frontend/ide/components/repo_commit_section_spec.js1
-rw-r--r--spec/frontend/ide/components/repo_tab_spec.js185
-rw-r--r--spec/frontend/ide/components/repo_tabs_spec.js (renamed from spec/javascripts/ide/components/repo_tabs_spec.js)0
-rw-r--r--spec/frontend/ide/components/shared/tokened_input_spec.js133
-rw-r--r--spec/frontend/ide/lib/common/model_manager_spec.js126
-rw-r--r--spec/frontend/ide/lib/common/model_spec.js137
-rw-r--r--spec/frontend/ide/lib/decorations/controller_spec.js143
-rw-r--r--spec/frontend/ide/lib/diff/controller_spec.js215
-rw-r--r--spec/frontend/ide/lib/editor_spec.js302
-rw-r--r--spec/frontend/ide/lib/languages/vue_spec.js92
-rw-r--r--spec/frontend/ide/services/index_spec.js63
-rw-r--r--spec/frontend/ide/stores/mutations_spec.js41
-rw-r--r--spec/frontend/ide/stores/utils_spec.js71
-rw-r--r--spec/frontend/ide/utils_spec.js92
-rw-r--r--spec/frontend/image_diff/helpers/badge_helper_spec.js130
-rw-r--r--spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js144
-rw-r--r--spec/frontend/image_diff/helpers/dom_helper_spec.js120
-rw-r--r--spec/frontend/image_diff/helpers/utils_helper_spec.js (renamed from spec/javascripts/image_diff/helpers/utils_helper_spec.js)0
-rw-r--r--spec/frontend/image_diff/image_badge_spec.js84
-rw-r--r--spec/frontend/image_diff/image_diff_spec.js361
-rw-r--r--spec/frontend/image_diff/mock_data.js (renamed from spec/javascripts/image_diff/mock_data.js)0
-rw-r--r--spec/frontend/image_diff/replaced_image_diff_spec.js356
-rw-r--r--spec/frontend/import_projects/components/import_projects_table_spec.js5
-rw-r--r--spec/frontend/import_projects/components/provider_repo_table_row_spec.js4
-rw-r--r--spec/frontend/integrations/edit/components/active_toggle_spec.js8
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js99
-rw-r--r--spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js97
-rw-r--r--spec/frontend/integrations/edit/components/trigger_fields_spec.js136
-rw-r--r--spec/frontend/integrations/integration_settings_form_spec.js268
-rw-r--r--spec/frontend/issuable_spec.js64
-rw-r--r--spec/frontend/issuables_list/components/issuable_list_root_app_spec.js121
-rw-r--r--spec/frontend/issue_show/components/app_spec.js497
-rw-r--r--spec/frontend/issue_show/components/description_spec.js188
-rw-r--r--spec/frontend/issue_show/components/edited_spec.js (renamed from spec/javascripts/issue_show/components/edited_spec.js)0
-rw-r--r--spec/frontend/issue_show/components/fields/description_template_spec.js41
-rw-r--r--spec/frontend/issue_show/components/form_spec.js99
-rw-r--r--spec/frontend/issue_show/components/title_spec.js95
-rw-r--r--spec/frontend/jira_import/components/jira_import_app_spec.js102
-rw-r--r--spec/frontend/jira_import/components/jira_import_form_spec.js31
-rw-r--r--spec/frontend/jira_import/components/jira_import_progress_spec.js21
-rw-r--r--spec/frontend/jira_import/components/jira_import_setup_spec.js18
-rw-r--r--spec/frontend/jira_import/utils_spec.js65
-rw-r--r--spec/frontend/jobs/components/artifacts_block_spec.js (renamed from spec/javascripts/jobs/components/artifacts_block_spec.js)0
-rw-r--r--spec/frontend/jobs/components/commit_block_spec.js89
-rw-r--r--spec/frontend/jobs/components/empty_state_spec.js (renamed from spec/javascripts/jobs/components/empty_state_spec.js)0
-rw-r--r--spec/frontend/jobs/components/environments_block_spec.js (renamed from spec/javascripts/jobs/components/environments_block_spec.js)0
-rw-r--r--spec/frontend/jobs/components/job_container_item_spec.js101
-rw-r--r--spec/frontend/jobs/components/job_log_spec.js65
-rw-r--r--spec/frontend/jobs/components/jobs_container_spec.js (renamed from spec/javascripts/jobs/components/jobs_container_spec.js)0
-rw-r--r--spec/frontend/jobs/components/log/line_header_spec.js2
-rw-r--r--spec/frontend/jobs/components/manual_variables_form_spec.js (renamed from spec/javascripts/jobs/components/manual_variables_form_spec.js)0
-rw-r--r--spec/frontend/jobs/components/sidebar_spec.js166
-rw-r--r--spec/frontend/jobs/components/stages_dropdown_spec.js163
-rw-r--r--spec/frontend/jobs/components/trigger_block_spec.js (renamed from spec/javascripts/jobs/components/trigger_block_spec.js)0
-rw-r--r--spec/frontend/jobs/components/unmet_prerequisites_block_spec.js (renamed from spec/javascripts/jobs/components/unmet_prerequisites_block_spec.js)0
-rw-r--r--spec/frontend/jobs/mixins/delayed_job_mixin_spec.js79
-rw-r--r--spec/frontend/jobs/store/actions_spec.js512
-rw-r--r--spec/frontend/jobs/store/helpers.js (renamed from spec/javascripts/jobs/store/helpers.js)0
-rw-r--r--spec/frontend/jobs/store/mutations_spec.js2
-rw-r--r--spec/frontend/labels_select_spec.js15
-rw-r--r--spec/frontend/landing_spec.js184
-rw-r--r--spec/frontend/lib/utils/axios_utils_spec.js1
-rw-r--r--spec/frontend/lib/utils/common_utils_spec.js2
-rw-r--r--spec/frontend/lib/utils/csrf_token_spec.js57
-rw-r--r--spec/frontend/lib/utils/downloader_spec.js40
-rw-r--r--spec/frontend/lib/utils/navigation_utility_spec.js23
-rw-r--r--spec/frontend/lib/utils/poll_spec.js225
-rw-r--r--spec/frontend/lib/utils/sticky_spec.js77
-rw-r--r--spec/frontend/lib/utils/text_markdown_spec.js8
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js147
-rw-r--r--spec/frontend/milestones/mock_data.js82
-rw-r--r--spec/frontend/milestones/project_milestone_combobox_spec.js150
-rw-r--r--spec/frontend/mocks/ce/diffs/workers/tree_worker.js9
-rw-r--r--spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js1
-rw-r--r--spec/frontend/mocks_spec.js13
-rw-r--r--spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap43
-rw-r--r--spec/frontend/monitoring/alert_widget_spec.js422
-rw-r--r--spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap25
-rw-r--r--spec/frontend/monitoring/components/alert_widget_form_spec.js220
-rw-r--r--spec/frontend/monitoring/components/charts/single_stat_spec.js14
-rw-r--r--spec/frontend/monitoring/components/charts/time_series_spec.js43
-rw-r--r--spec/frontend/monitoring/components/dashboard_panel_spec.js576
-rw-r--r--spec/frontend/monitoring/components/dashboard_spec.js549
-rw-r--r--spec/frontend/monitoring/components/dashboard_template_spec.js15
-rw-r--r--spec/frontend/monitoring/components/dashboard_url_time_spec.js2
-rw-r--r--spec/frontend/monitoring/components/dashboards_dropdown_spec.js127
-rw-r--r--spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js26
-rw-r--r--spec/frontend/monitoring/components/embeds/metric_embed_spec.js10
-rw-r--r--spec/frontend/monitoring/components/panel_type_spec.js408
-rw-r--r--spec/frontend/monitoring/components/variables/custom_variable_spec.js52
-rw-r--r--spec/frontend/monitoring/components/variables/text_variable_spec.js59
-rw-r--r--spec/frontend/monitoring/components/variables_section_spec.js126
-rw-r--r--spec/frontend/monitoring/mock_data.js231
-rw-r--r--spec/frontend/monitoring/store/actions_spec.js180
-rw-r--r--spec/frontend/monitoring/store/getters_spec.js84
-rw-r--r--spec/frontend/monitoring/store/mutations_spec.js92
-rw-r--r--spec/frontend/monitoring/store/utils_spec.js6
-rw-r--r--spec/frontend/monitoring/store/variable_mapping_spec.js22
-rw-r--r--spec/frontend/monitoring/store_utils.js43
-rw-r--r--spec/frontend/monitoring/stubs/modal_stub.js11
-rw-r--r--spec/frontend/monitoring/utils_spec.js302
-rw-r--r--spec/frontend/monitoring/validators_spec.js80
-rw-r--r--spec/frontend/notebook/cells/code_spec.js90
-rw-r--r--spec/frontend/notebook/cells/markdown_spec.js167
-rw-r--r--spec/frontend/notebook/cells/output/html_sanitize_tests.js (renamed from spec/javascripts/notebook/cells/output/html_sanitize_tests.js)0
-rw-r--r--spec/frontend/notebook/cells/output/html_spec.js (renamed from spec/javascripts/notebook/cells/output/html_spec.js)0
-rw-r--r--spec/frontend/notebook/cells/output/index_spec.js115
-rw-r--r--spec/frontend/notebook/cells/prompt_spec.js56
-rw-r--r--spec/frontend/notebook/index_spec.js100
-rw-r--r--spec/frontend/notes/components/comment_form_spec.js7
-rw-r--r--spec/frontend/notes/components/discussion_actions_spec.js2
-rw-r--r--spec/frontend/notes/components/discussion_counter_spec.js9
-rw-r--r--spec/frontend/notes/components/discussion_filter_spec.js4
-rw-r--r--spec/frontend/notes/components/discussion_notes_spec.js2
-rw-r--r--spec/frontend/notes/components/note_form_spec.js8
-rw-r--r--spec/frontend/notes/components/note_header_spec.js94
-rw-r--r--spec/frontend/notes/components/noteable_discussion_spec.js2
-rw-r--r--spec/frontend/notes/components/notes_app_spec.js2
-rw-r--r--spec/frontend/notes/mixins/discussion_navigation_spec.js4
-rw-r--r--spec/frontend/notes/mock_data.js1
-rw-r--r--spec/frontend/notes/old_notes_spec.js52
-rw-r--r--spec/frontend/notes/stores/actions_spec.js31
-rw-r--r--spec/frontend/notes/stores/collapse_utils_spec.js4
-rw-r--r--spec/frontend/notes/stores/mutation_spec.js56
-rw-r--r--spec/frontend/oauth_remember_me_spec.js (renamed from spec/javascripts/oauth_remember_me_spec.js)0
-rw-r--r--spec/frontend/pages/admin/application_settings/account_and_limits_spec.js (renamed from spec/javascripts/pages/admin/application_settings/account_and_limits_spec.js)0
-rw-r--r--spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js64
-rw-r--r--spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap1
-rw-r--r--spec/frontend/pages/admin/users/new/index_spec.js (renamed from spec/javascripts/pages/admin/users/new/index_spec.js)0
-rw-r--r--spec/frontend/pages/labels/components/promote_label_modal_spec.js103
-rw-r--r--spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js109
-rw-r--r--spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js98
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js154
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js114
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js29
-rw-r--r--spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js (renamed from spec/javascripts/pages/sessions/new/preserve_url_fragment_spec.js)0
-rw-r--r--spec/frontend/pipelines/components/pipelines_filtered_search_spec.js97
-rw-r--r--spec/frontend/pipelines/graph/stage_column_component_spec.js2
-rw-r--r--spec/frontend/pipelines/header_component_spec.js116
-rw-r--r--spec/frontend/pipelines/linked_pipelines_mock.json3536
-rw-r--r--spec/frontend/pipelines/mock_data.js568
-rw-r--r--spec/frontend/pipelines/pipeline_details_mediator_spec.js36
-rw-r--r--spec/frontend/pipelines/pipelines_actions_spec.js142
-rw-r--r--spec/frontend/pipelines/pipelines_artifacts_spec.js46
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js710
-rw-r--r--spec/frontend/pipelines/pipelines_table_row_spec.js2
-rw-r--r--spec/frontend/pipelines/pipelines_table_spec.js66
-rw-r--r--spec/frontend/pipelines/stage_spec.js156
-rw-r--r--spec/frontend/pipelines/stores/pipeline_store_spec.js135
-rw-r--r--spec/frontend/pipelines/test_reports/stores/mutations_spec.js6
-rw-r--r--spec/frontend/pipelines/test_reports/test_summary_spec.js18
-rw-r--r--spec/frontend/pipelines/test_reports/test_summary_table_spec.js36
-rw-r--r--spec/frontend/pipelines/time_ago_spec.js67
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js89
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js98
-rw-r--r--spec/frontend/pipelines_spec.js (renamed from spec/javascripts/pipelines_spec.js)0
-rw-r--r--spec/frontend/prometheus_metrics/custom_metrics_spec.js2
-rw-r--r--spec/frontend/prometheus_metrics/mock_data.js44
-rw-r--r--spec/frontend/prometheus_metrics/prometheus_metrics_spec.js178
-rw-r--r--spec/frontend/registry/explorer/components/image_list_spec.js74
-rw-r--r--spec/frontend/registry/explorer/mock_data.js8
-rw-r--r--spec/frontend/registry/explorer/pages/details_spec.js198
-rw-r--r--spec/frontend/registry/explorer/pages/index_spec.js36
-rw-r--r--spec/frontend/registry/explorer/pages/list_spec.js269
-rw-r--r--spec/frontend/registry/explorer/stores/actions_spec.js20
-rw-r--r--spec/frontend/registry/explorer/stubs.js5
-rw-r--r--spec/frontend/registry/settings/store/getters_spec.js14
-rw-r--r--spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap18
-rw-r--r--spec/frontend/registry/shared/components/expiration_policy_fields_spec.js37
-rw-r--r--spec/frontend/related_merge_requests/components/related_merge_requests_spec.js94
-rw-r--r--spec/frontend/related_merge_requests/store/actions_spec.js111
-rw-r--r--spec/frontend/related_merge_requests/store/mutations_spec.js (renamed from spec/javascripts/related_merge_requests/store/mutations_spec.js)0
-rw-r--r--spec/frontend/releases/components/app_edit_spec.js9
-rw-r--r--spec/frontend/releases/components/release_block_footer_spec.js81
-rw-r--r--spec/frontend/releases/components/release_block_metadata_spec.js67
-rw-r--r--spec/frontend/releases/components/release_block_milestone_info_spec.js4
-rw-r--r--spec/frontend/releases/components/release_block_spec.js13
-rw-r--r--spec/frontend/releases/stores/modules/detail/actions_spec.js12
-rw-r--r--spec/frontend/releases/stores/modules/detail/mutations_spec.js31
-rw-r--r--spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js126
-rw-r--r--spec/frontend/reports/accessibility_report/mock_data.js55
-rw-r--r--spec/frontend/reports/accessibility_report/store/actions_spec.js121
-rw-r--r--spec/frontend/reports/accessibility_report/store/getters_spec.js149
-rw-r--r--spec/frontend/reports/accessibility_report/store/mutations_spec.js64
-rw-r--r--spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap25
-rw-r--r--spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap37
-rw-r--r--spec/frontend/reports/components/grouped_issues_list_spec.js86
-rw-r--r--spec/frontend/reports/components/grouped_test_reports_app_spec.js260
-rw-r--r--spec/frontend/reports/components/issue_status_icon_spec.js29
-rw-r--r--spec/frontend/reports/components/modal_open_name_spec.js47
-rw-r--r--spec/frontend/reports/components/modal_spec.js (renamed from spec/javascripts/reports/components/modal_spec.js)0
-rw-r--r--spec/frontend/reports/components/summary_row_spec.js37
-rw-r--r--spec/frontend/reports/components/test_issue_body_spec.js72
-rw-r--r--spec/frontend/reports/mock_data/mock_data.js24
-rw-r--r--spec/frontend/reports/mock_data/new_and_fixed_failures_report.json (renamed from spec/javascripts/reports/mock_data/new_and_fixed_failures_report.json)0
-rw-r--r--spec/frontend/reports/mock_data/new_errors_report.json (renamed from spec/javascripts/reports/mock_data/new_errors_report.json)0
-rw-r--r--spec/frontend/reports/mock_data/new_failures_report.json (renamed from spec/javascripts/reports/mock_data/new_failures_report.json)0
-rw-r--r--spec/frontend/reports/mock_data/no_failures_report.json (renamed from spec/javascripts/reports/mock_data/no_failures_report.json)0
-rw-r--r--spec/frontend/reports/mock_data/resolved_failures.json (renamed from spec/javascripts/reports/mock_data/resolved_failures.json)0
-rw-r--r--spec/frontend/reports/store/actions_spec.js171
-rw-r--r--spec/frontend/reports/store/mutations_spec.js (renamed from spec/javascripts/reports/store/mutations_spec.js)0
-rw-r--r--spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap8
-rw-r--r--spec/frontend/repository/components/last_commit_spec.js1
-rw-r--r--spec/frontend/repository/utils/commit_spec.js2
-rw-r--r--spec/frontend/settings_panels_spec.js (renamed from spec/javascripts/settings_panels_spec.js)0
-rw-r--r--spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap12
-rw-r--r--spec/frontend/sidebar/assignees_realtime_spec.js102
-rw-r--r--spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js279
-rw-r--r--spec/frontend/sidebar/confidential/edit_form_buttons_spec.js41
-rw-r--r--spec/frontend/sidebar/confidential/edit_form_spec.js45
-rw-r--r--spec/frontend/sidebar/confidential_edit_buttons_spec.js35
-rw-r--r--spec/frontend/sidebar/confidential_edit_form_buttons_spec.js35
-rw-r--r--spec/frontend/sidebar/confidential_issue_sidebar_spec.js25
-rw-r--r--spec/frontend/sidebar/lock/edit_form_buttons_spec.js31
-rw-r--r--spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js99
-rw-r--r--spec/frontend/sidebar/participants_spec.js206
-rw-r--r--spec/frontend/sidebar/sidebar_assignees_spec.js46
-rw-r--r--spec/frontend/sidebar/sidebar_mediator_spec.js135
-rw-r--r--spec/frontend/sidebar/sidebar_move_issue_spec.js167
-rw-r--r--spec/frontend/sidebar/sidebar_subscriptions_spec.js36
-rw-r--r--spec/frontend/sidebar/subscriptions_spec.js106
-rw-r--r--spec/frontend/smart_interval_spec.js2
-rw-r--r--spec/frontend/snippet/snippet_bundle_spec.js141
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap1
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap4
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap16
-rw-r--r--spec/frontend/snippets/components/edit_spec.js16
-rw-r--r--spec/frontend/snippets/components/snippet_blob_view_spec.js38
-rw-r--r--spec/frontend/snippets/components/snippet_description_view_spec.js27
-rw-r--r--spec/frontend/snippets/components/snippet_header_spec.js86
-rw-r--r--spec/frontend/snippets/components/snippet_title_spec.js6
-rw-r--r--spec/frontend/static_site_editor/components/edit_area_spec.js76
-rw-r--r--spec/frontend/static_site_editor/components/publish_toolbar_spec.js17
-rw-r--r--spec/frontend/static_site_editor/components/saved_changes_message_spec.js7
-rw-r--r--spec/frontend/static_site_editor/components/static_site_editor_spec.js247
-rw-r--r--spec/frontend/static_site_editor/graphql/resolvers/file_spec.js25
-rw-r--r--spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js37
-rw-r--r--spec/frontend/static_site_editor/mock_data.js5
-rw-r--r--spec/frontend/static_site_editor/pages/home_spec.js211
-rw-r--r--spec/frontend/static_site_editor/pages/success_spec.js78
-rw-r--r--spec/frontend/static_site_editor/services/submit_content_changes_spec.js32
-rw-r--r--spec/frontend/static_site_editor/store/actions_spec.js152
-rw-r--r--spec/frontend/static_site_editor/store/getters_spec.js19
-rw-r--r--spec/frontend/static_site_editor/store/mutations_spec.js54
-rw-r--r--spec/frontend/tracking_spec.js57
-rw-r--r--spec/frontend/users_select/utils_spec.js33
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js4
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js100
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js18
-rw-r--r--spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js165
-rw-r--r--spec/frontend/vue_mr_widget/stores/get_state_key_spec.js24
-rw-r--r--spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js112
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap38
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap16
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap12
-rw-r--r--spec/frontend/vue_shared/components/awards_list_spec.js42
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap3
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/ci_badge_link_spec.js100
-rw-r--r--spec/frontend/vue_shared/components/ci_icon_spec.js122
-rw-r--r--spec/frontend/vue_shared/components/code_block_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js21
-rw-r--r--spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js20
-rw-r--r--spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js28
-rw-r--r--spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js114
-rw-r--r--spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js9
-rw-r--r--spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js98
-rw-r--r--spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js81
-rw-r--r--spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js36
-rw-r--r--spec/frontend/vue_shared/components/dropdown/mock_data.js (renamed from spec/javascripts/vue_shared/components/dropdown/mock_data.js)0
-rw-r--r--spec/frontend/vue_shared/components/file_finder/item_spec.js140
-rw-r--r--spec/frontend/vue_shared/components/file_row_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js190
-rw-r--r--spec/frontend/vue_shared/components/gl_countdown_spec.js83
-rw-r--r--spec/frontend/vue_shared/components/header_ci_component_spec.js93
-rw-r--r--spec/frontend/vue_shared/components/identicon_spec.js37
-rw-r--r--spec/frontend/vue_shared/components/issue/issue_milestone_spec.js44
-rw-r--r--spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_spec.js34
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_view_spec.js26
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestions_spec.js102
-rw-r--r--spec/frontend/vue_shared/components/markdown/toolbar_spec.js35
-rw-r--r--spec/frontend/vue_shared/components/navigation_tabs_spec.js64
-rw-r--r--spec/frontend/vue_shared/components/pikaday_spec.js30
-rw-r--r--spec/frontend/vue_shared/components/project_avatar/default_spec.js58
-rw-r--r--spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js109
-rw-r--r--spec/frontend/vue_shared/components/project_selector/project_selector_spec.js112
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js44
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js20
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_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/dropdown_value_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js (renamed from spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js)0
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js25
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js91
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js111
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js24
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js3
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js24
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/stacked_progress_bar_spec.js104
-rw-r--r--spec/frontend/vue_shared/components/tabs/tab_spec.js32
-rw-r--r--spec/frontend/vue_shared/components/tabs/tabs_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/toggle_button_spec.js101
-rw-r--r--spec/frontend/wikis_spec.js26
-rw-r--r--spec/frontend_integration/.eslintrc.yml6
-rw-r--r--spec/frontend_integration/README.md17
-rw-r--r--spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap136
-rw-r--r--spec/frontend_integration/ide/ide_integration_spec.js100
-rw-r--r--spec/graphql/gitlab_schema_spec.rb16
-rw-r--r--spec/graphql/mutations/alert_management/create_alert_issue_spec.rb60
-rw-r--r--spec/graphql/mutations/alert_management/update_alert_status_spec.rb73
-rw-r--r--spec/graphql/mutations/branches/create_spec.rb55
-rw-r--r--spec/graphql/mutations/design_management/delete_spec.rb145
-rw-r--r--spec/graphql/mutations/design_management/upload_spec.rb136
-rw-r--r--spec/graphql/mutations/issues/set_confidential_spec.rb2
-rw-r--r--spec/graphql/mutations/issues/set_due_date_spec.rb2
-rw-r--r--spec/graphql/mutations/issues/update_spec.rb2
-rw-r--r--spec/graphql/mutations/merge_requests/set_labels_spec.rb2
-rw-r--r--spec/graphql/mutations/merge_requests/set_locked_spec.rb2
-rw-r--r--spec/graphql/mutations/merge_requests/set_milestone_spec.rb2
-rw-r--r--spec/graphql/mutations/merge_requests/set_subscription_spec.rb2
-rw-r--r--spec/graphql/mutations/merge_requests/set_wip_spec.rb2
-rw-r--r--spec/graphql/mutations/todos/mark_all_done_spec.rb2
-rw-r--r--spec/graphql/mutations/todos/mark_done_spec.rb2
-rw-r--r--spec/graphql/mutations/todos/restore_spec.rb2
-rw-r--r--spec/graphql/resolvers/alert_management/alert_status_counts_resolver_spec.rb24
-rw-r--r--spec/graphql/resolvers/alert_management_alert_resolver_spec.rb63
-rw-r--r--spec/graphql/resolvers/board_lists_resolver_spec.rb82
-rw-r--r--spec/graphql/resolvers/branch_commit_resolver_spec.rb26
-rw-r--r--spec/graphql/resolvers/design_management/design_at_version_resolver_spec.rb69
-rw-r--r--spec/graphql/resolvers/design_management/design_resolver_spec.rb88
-rw-r--r--spec/graphql/resolvers/design_management/designs_resolver_spec.rb93
-rw-r--r--spec/graphql/resolvers/design_management/version/design_at_version_resolver_spec.rb93
-rw-r--r--spec/graphql/resolvers/design_management/version/designs_at_version_resolver_spec.rb86
-rw-r--r--spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb64
-rw-r--r--spec/graphql/resolvers/design_management/version_resolver_spec.rb43
-rw-r--r--spec/graphql/resolvers/design_management/versions_resolver_spec.rb117
-rw-r--r--spec/graphql/resolvers/issues_resolver_spec.rb78
-rw-r--r--spec/graphql/resolvers/milestone_resolver_spec.rb28
-rw-r--r--spec/graphql/resolvers/projects/jira_imports_resolver_spec.rb8
-rw-r--r--spec/graphql/resolvers/projects_resolver_spec.rb77
-rw-r--r--spec/graphql/resolvers/release_resolver_spec.rb51
-rw-r--r--spec/graphql/resolvers/releases_resolver_spec.rb42
-rw-r--r--spec/graphql/types/alert_management/alert_status_count_type_spec.rb20
-rw-r--r--spec/graphql/types/alert_management/alert_type_spec.rb31
-rw-r--r--spec/graphql/types/alert_management/severity_enum_spec.rb11
-rw-r--r--spec/graphql/types/alert_management/status_enum_spec.rb24
-rw-r--r--spec/graphql/types/award_emojis/award_emoji_type_spec.rb6
-rw-r--r--spec/graphql/types/blob_viewers/type_enum_spec.rb2
-rw-r--r--spec/graphql/types/board_list_type_spec.rb13
-rw-r--r--spec/graphql/types/board_type_spec.rb4
-rw-r--r--spec/graphql/types/branch_type_spec.rb9
-rw-r--r--spec/graphql/types/ci/detailed_status_type_spec.rb2
-rw-r--r--spec/graphql/types/ci/pipeline_type_spec.rb4
-rw-r--r--spec/graphql/types/commit_type_spec.rb6
-rw-r--r--spec/graphql/types/design_management/design_at_version_type_spec.rb16
-rw-r--r--spec/graphql/types/design_management/design_collection_type_spec.rb13
-rw-r--r--spec/graphql/types/design_management/design_type_spec.rb13
-rw-r--r--spec/graphql/types/design_management/design_version_event_enum_spec.rb11
-rw-r--r--spec/graphql/types/design_management/version_type_spec.rb13
-rw-r--r--spec/graphql/types/design_management_type_spec.rb7
-rw-r--r--spec/graphql/types/diff_refs_type_spec.rb10
-rw-r--r--spec/graphql/types/environment_type_spec.rb4
-rw-r--r--spec/graphql/types/error_tracking/sentry_detailed_error_type_spec.rb4
-rw-r--r--spec/graphql/types/error_tracking/sentry_error_collection_type_spec.rb4
-rw-r--r--spec/graphql/types/error_tracking/sentry_error_stack_trace_entry_type_spec.rb2
-rw-r--r--spec/graphql/types/error_tracking/sentry_error_stack_trace_type_spec.rb4
-rw-r--r--spec/graphql/types/error_tracking/sentry_error_type_spec.rb2
-rw-r--r--spec/graphql/types/grafana_integration_type_spec.rb6
-rw-r--r--spec/graphql/types/group_type_spec.rb6
-rw-r--r--spec/graphql/types/issuable_sort_enum_spec.rb15
-rw-r--r--spec/graphql/types/issuable_state_enum_spec.rb2
-rw-r--r--spec/graphql/types/issue_sort_enum_spec.rb6
-rw-r--r--spec/graphql/types/issue_state_enum_spec.rb2
-rw-r--r--spec/graphql/types/issue_type_spec.rb11
-rw-r--r--spec/graphql/types/jira_import_type_spec.rb4
-rw-r--r--spec/graphql/types/label_type_spec.rb2
-rw-r--r--spec/graphql/types/merge_request_state_enum_spec.rb2
-rw-r--r--spec/graphql/types/merge_request_type_spec.rb6
-rw-r--r--spec/graphql/types/metadata_type_spec.rb4
-rw-r--r--spec/graphql/types/metrics/dashboard_type_spec.rb2
-rw-r--r--spec/graphql/types/metrics/dashboards/annotation_type_spec.rb4
-rw-r--r--spec/graphql/types/milestone_type_spec.rb4
-rw-r--r--spec/graphql/types/namespace_type_spec.rb4
-rw-r--r--spec/graphql/types/notes/discussion_type_spec.rb4
-rw-r--r--spec/graphql/types/notes/note_type_spec.rb4
-rw-r--r--spec/graphql/types/notes/noteable_type_spec.rb3
-rw-r--r--spec/graphql/types/permission_types/issue_spec.rb5
-rw-r--r--spec/graphql/types/permission_types/merge_request_type_spec.rb2
-rw-r--r--spec/graphql/types/permission_types/project_spec.rb2
-rw-r--r--spec/graphql/types/project_type_spec.rb23
-rw-r--r--spec/graphql/types/projects/base_service_type_spec.rb4
-rw-r--r--spec/graphql/types/projects/jira_service_type_spec.rb4
-rw-r--r--spec/graphql/types/projects/service_type_spec.rb2
-rw-r--r--spec/graphql/types/projects/services_enum_spec.rb2
-rw-r--r--spec/graphql/types/query_type_spec.rb2
-rw-r--r--spec/graphql/types/release_type_spec.rb37
-rw-r--r--spec/graphql/types/repository_type_spec.rb8
-rw-r--r--spec/graphql/types/root_storage_statistics_type_spec.rb4
-rw-r--r--spec/graphql/types/snippet_type_spec.rb10
-rw-r--r--spec/graphql/types/snippets/blob_type_spec.rb16
-rw-r--r--spec/graphql/types/snippets/blob_viewer_type_spec.rb81
-rw-r--r--spec/graphql/types/time_type_spec.rb2
-rw-r--r--spec/graphql/types/todo_type_spec.rb2
-rw-r--r--spec/graphql/types/tree/blob_type_spec.rb4
-rw-r--r--spec/graphql/types/tree/submodule_type_spec.rb4
-rw-r--r--spec/graphql/types/tree/tree_entry_type_spec.rb4
-rw-r--r--spec/graphql/types/tree/tree_type_spec.rb4
-rw-r--r--spec/graphql/types/tree/type_enum_spec.rb2
-rw-r--r--spec/graphql/types/user_type_spec.rb6
-rw-r--r--spec/haml_lint/linter/no_plain_nodes_spec.rb38
-rw-r--r--spec/helpers/access_tokens_helper_spec.rb18
-rw-r--r--spec/helpers/application_helper_spec.rb27
-rw-r--r--spec/helpers/auth_helper_spec.rb36
-rw-r--r--spec/helpers/boards_helper_spec.rb4
-rw-r--r--spec/helpers/clusters_helper_spec.rb26
-rw-r--r--spec/helpers/commits_helper_spec.rb28
-rw-r--r--spec/helpers/environments_helper_spec.rb18
-rw-r--r--spec/helpers/events_helper_spec.rb14
-rw-r--r--spec/helpers/export_helper_spec.rb11
-rw-r--r--spec/helpers/groups_helper_spec.rb27
-rw-r--r--spec/helpers/issuables_helper_spec.rb42
-rw-r--r--spec/helpers/markup_helper_spec.rb39
-rw-r--r--spec/helpers/members_helper_spec.rb11
-rw-r--r--spec/helpers/milestones_helper_spec.rb15
-rw-r--r--spec/helpers/nav_helper_spec.rb23
-rw-r--r--spec/helpers/preferences_helper_spec.rb2
-rw-r--r--spec/helpers/projects/alert_management_helper_spec.rb82
-rw-r--r--spec/helpers/projects_helper_spec.rb42
-rw-r--r--spec/helpers/releases_helper_spec.rb4
-rw-r--r--spec/helpers/search_helper_spec.rb1
-rw-r--r--spec/helpers/snippets_helper_spec.rb31
-rw-r--r--spec/helpers/todos_helper_spec.rb71
-rw-r--r--spec/helpers/visibility_level_helper_spec.rb26
-rw-r--r--spec/helpers/x509_helper_spec.rb18
-rw-r--r--spec/initializers/action_mailer_hooks_spec.rb4
-rw-r--r--spec/initializers/lograge_spec.rb2
-rw-r--r--spec/initializers/secret_token_spec.rb9
-rw-r--r--spec/initializers/zz_metrics_spec.rb4
-rw-r--r--spec/javascripts/ajax_loading_spinner_spec.js57
-rw-r--r--spec/javascripts/avatar_helper_spec.js98
-rw-r--r--spec/javascripts/bootstrap_linked_tabs_spec.js67
-rw-r--r--spec/javascripts/ci_variable_list/ajax_variable_list_spec.js231
-rw-r--r--spec/javascripts/ci_variable_list/ci_variable_list_spec.js294
-rw-r--r--spec/javascripts/close_reopen_report_toggle_spec.js272
-rw-r--r--spec/javascripts/commits_spec.js98
-rw-r--r--spec/javascripts/deploy_keys/components/action_btn_spec.js72
-rw-r--r--spec/javascripts/deploy_keys/components/app_spec.js155
-rw-r--r--spec/javascripts/deploy_keys/components/key_spec.js157
-rw-r--r--spec/javascripts/deploy_keys/components/keys_panel_spec.js63
-rw-r--r--spec/javascripts/diff_comments_store_spec.js141
-rw-r--r--spec/javascripts/diffs/create_diffs_store.js5
-rw-r--r--spec/javascripts/diffs/mock_data/diff_discussions.js5
-rw-r--r--spec/javascripts/diffs/mock_data/diff_file.js5
-rw-r--r--spec/javascripts/diffs/mock_data/diff_file_unreadable.js5
-rw-r--r--spec/javascripts/diffs/mock_data/diff_with_commit.js7
-rw-r--r--spec/javascripts/diffs/mock_data/merge_request_diffs.js7
-rw-r--r--spec/javascripts/dirty_submit/dirty_submit_collection_spec.js29
-rw-r--r--spec/javascripts/dirty_submit/dirty_submit_form_spec.js114
-rw-r--r--spec/javascripts/dirty_submit/helper.js48
-rw-r--r--spec/javascripts/editor/editor_lite_spec.js160
-rw-r--r--spec/javascripts/emoji_spec.js486
-rw-r--r--spec/javascripts/feature_highlight/feature_highlight_helper_spec.js75
-rw-r--r--spec/javascripts/feature_highlight/feature_highlight_spec.js141
-rw-r--r--spec/javascripts/filtered_search/dropdown_utils_spec.js374
-rw-r--r--spec/javascripts/filtered_search/filtered_search_manager_spec.js580
-rw-r--r--spec/javascripts/filtered_search/recent_searches_root_spec.js30
-rw-r--r--spec/javascripts/filtered_search/services/recent_searches_service_spec.js158
-rw-r--r--spec/javascripts/filtered_search/visual_token_value_spec.js389
-rw-r--r--spec/javascripts/flash_spec.js236
-rw-r--r--spec/javascripts/frequent_items/components/app_spec.js257
-rw-r--r--spec/javascripts/frequent_items/mock_data.js168
-rw-r--r--spec/javascripts/frequent_items/store/actions_spec.js228
-rw-r--r--spec/javascripts/frequent_items/utils_spec.js130
-rw-r--r--spec/javascripts/gl_dropdown_spec.js22
-rw-r--r--spec/javascripts/groups/components/app_spec.js533
-rw-r--r--spec/javascripts/groups/components/group_folder_spec.js67
-rw-r--r--spec/javascripts/groups/components/group_item_spec.js218
-rw-r--r--spec/javascripts/groups/components/groups_spec.js76
-rw-r--r--spec/javascripts/groups/components/item_actions_spec.js84
-rw-r--r--spec/javascripts/groups/components/item_caret_spec.js38
-rw-r--r--spec/javascripts/groups/components/item_stats_spec.js128
-rw-r--r--spec/javascripts/groups/components/item_stats_value_spec.js82
-rw-r--r--spec/javascripts/groups/components/item_type_icon_spec.js58
-rw-r--r--spec/javascripts/groups/service/groups_service_spec.js42
-rw-r--r--spec/javascripts/groups/store/groups_store_spec.js123
-rw-r--r--spec/javascripts/helpers/filtered_search_spec_helper.js70
-rw-r--r--spec/javascripts/helpers/init_vue_mr_page_helper.js2
-rw-r--r--spec/javascripts/helpers/vue_mount_component_helper.js40
-rw-r--r--spec/javascripts/ide/components/activity_bar_spec.js72
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js139
-rw-r--r--spec/javascripts/ide/components/file_row_extra_spec.js170
-rw-r--r--spec/javascripts/ide/components/file_templates/bar_spec.js117
-rw-r--r--spec/javascripts/ide/components/ide_review_spec.js69
-rw-r--r--spec/javascripts/ide/components/ide_side_bar_spec.js57
-rw-r--r--spec/javascripts/ide/components/ide_spec.js125
-rw-r--r--spec/javascripts/ide/components/ide_status_bar_spec.js129
-rw-r--r--spec/javascripts/ide/components/ide_tree_list_spec.js77
-rw-r--r--spec/javascripts/ide/components/ide_tree_spec.js34
-rw-r--r--spec/javascripts/ide/components/merge_requests/item_spec.js63
-rw-r--r--spec/javascripts/ide/components/nav_dropdown_button_spec.js93
-rw-r--r--spec/javascripts/ide/components/nav_dropdown_spec.js80
-rw-r--r--spec/javascripts/ide/components/new_dropdown/button_spec.js65
-rw-r--r--spec/javascripts/ide/components/new_dropdown/index_spec.js84
-rw-r--r--spec/javascripts/ide/components/new_dropdown/modal_spec.js150
-rw-r--r--spec/javascripts/ide/components/new_dropdown/upload_spec.js112
-rw-r--r--spec/javascripts/ide/components/repo_editor_spec.js43
-rw-r--r--spec/javascripts/ide/components/repo_tab_spec.js185
-rw-r--r--spec/javascripts/ide/components/shared/tokened_input_spec.js133
-rw-r--r--spec/javascripts/ide/lib/common/model_manager_spec.js126
-rw-r--r--spec/javascripts/ide/lib/common/model_spec.js137
-rw-r--r--spec/javascripts/ide/lib/decorations/controller_spec.js143
-rw-r--r--spec/javascripts/ide/lib/diff/controller_spec.js215
-rw-r--r--spec/javascripts/ide/lib/editor_spec.js287
-rw-r--r--spec/javascripts/ide/stores/actions/tree_spec.js3
-rw-r--r--spec/javascripts/image_diff/helpers/badge_helper_spec.js130
-rw-r--r--spec/javascripts/image_diff/helpers/comment_indicator_helper_spec.js144
-rw-r--r--spec/javascripts/image_diff/helpers/dom_helper_spec.js120
-rw-r--r--spec/javascripts/image_diff/image_badge_spec.js96
-rw-r--r--spec/javascripts/image_diff/image_diff_spec.js361
-rw-r--r--spec/javascripts/image_diff/replaced_image_diff_spec.js355
-rw-r--r--spec/javascripts/integrations/integration_settings_form_spec.js301
-rw-r--r--spec/javascripts/issuable_spec.js64
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js568
-rw-r--r--spec/javascripts/issue_show/components/description_spec.js210
-rw-r--r--spec/javascripts/issue_show/components/fields/description_template_spec.js43
-rw-r--r--spec/javascripts/issue_show/components/form_spec.js98
-rw-r--r--spec/javascripts/issue_show/components/title_spec.js105
-rw-r--r--spec/javascripts/issue_show/helpers.js1
-rw-r--r--spec/javascripts/issue_show/mock_data.js1
-rw-r--r--spec/javascripts/jobs/components/commit_block_spec.js89
-rw-r--r--spec/javascripts/jobs/components/job_container_item_spec.js99
-rw-r--r--spec/javascripts/jobs/components/job_log_spec.js65
-rw-r--r--spec/javascripts/jobs/components/sidebar_spec.js169
-rw-r--r--spec/javascripts/jobs/components/stages_dropdown_spec.js163
-rw-r--r--spec/javascripts/jobs/mixins/delayed_job_mixin_spec.js93
-rw-r--r--spec/javascripts/jobs/store/actions_spec.js512
-rw-r--r--spec/javascripts/landing_spec.js166
-rw-r--r--spec/javascripts/lib/utils/csrf_token_spec.js50
-rw-r--r--spec/javascripts/lib/utils/navigation_utility_spec.js23
-rw-r--r--spec/javascripts/lib/utils/poll_spec.js222
-rw-r--r--spec/javascripts/lib/utils/sticky_spec.js66
-rw-r--r--spec/javascripts/line_highlighter_spec.js17
-rw-r--r--spec/javascripts/monitoring/components/dashboard_resize_browser_spec.js94
-rw-r--r--spec/javascripts/monitoring/components/dashboard_resize_spec.js87
-rw-r--r--spec/javascripts/notebook/cells/code_spec.js74
-rw-r--r--spec/javascripts/notebook/cells/markdown_spec.js105
-rw-r--r--spec/javascripts/notebook/cells/output/index_spec.js115
-rw-r--r--spec/javascripts/notebook/cells/prompt_spec.js56
-rw-r--r--spec/javascripts/notebook/index_spec.js100
-rw-r--r--spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js61
-rw-r--r--spec/javascripts/pages/labels/components/promote_label_modal_spec.js103
-rw-r--r--spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js106
-rw-r--r--spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js98
-rw-r--r--spec/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js192
-rw-r--r--spec/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js106
-rw-r--r--spec/javascripts/pipelines/header_component_spec.js108
-rw-r--r--spec/javascripts/pipelines/linked_pipelines_mock.json3535
-rw-r--r--spec/javascripts/pipelines/mock_data.js423
-rw-r--r--spec/javascripts/pipelines/pipeline_details_mediator_spec.js36
-rw-r--r--spec/javascripts/pipelines/pipelines_actions_spec.js128
-rw-r--r--spec/javascripts/pipelines/pipelines_artifacts_spec.js38
-rw-r--r--spec/javascripts/pipelines/pipelines_spec.js783
-rw-r--r--spec/javascripts/pipelines/pipelines_table_spec.js86
-rw-r--r--spec/javascripts/pipelines/stage_spec.js136
-rw-r--r--spec/javascripts/pipelines/stores/pipeline.json167
-rw-r--r--spec/javascripts/pipelines/stores/pipeline_store.js165
-rw-r--r--spec/javascripts/pipelines/stores/pipeline_with_triggered.json381
-rw-r--r--spec/javascripts/pipelines/stores/pipeline_with_triggered_by.json379
-rw-r--r--spec/javascripts/pipelines/stores/pipeline_with_triggered_triggered_by.json452
-rw-r--r--spec/javascripts/pipelines/time_ago_spec.js64
-rw-r--r--spec/javascripts/prometheus_metrics/mock_data.js41
-rw-r--r--spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js178
-rw-r--r--spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js88
-rw-r--r--spec/javascripts/related_merge_requests/store/actions_spec.js110
-rw-r--r--spec/javascripts/reports/components/grouped_test_reports_app_spec.js239
-rw-r--r--spec/javascripts/reports/components/modal_open_name_spec.js47
-rw-r--r--spec/javascripts/reports/components/summary_row_spec.js37
-rw-r--r--spec/javascripts/reports/components/test_issue_body_spec.js72
-rw-r--r--spec/javascripts/reports/mock_data/mock_data.js8
-rw-r--r--spec/javascripts/reports/store/actions_spec.js171
-rw-r--r--spec/javascripts/search_autocomplete_spec.js24
-rw-r--r--spec/javascripts/sidebar/components/time_tracking/time_tracker_spec.js278
-rw-r--r--spec/javascripts/sidebar/lock/edit_form_buttons_spec.js32
-rw-r--r--spec/javascripts/sidebar/lock/lock_issue_sidebar_spec.js99
-rw-r--r--spec/javascripts/sidebar/mock_data.js7
-rw-r--r--spec/javascripts/sidebar/participants_spec.js202
-rw-r--r--spec/javascripts/sidebar/sidebar_mediator_spec.js134
-rw-r--r--spec/javascripts/sidebar/sidebar_move_issue_spec.js166
-rw-r--r--spec/javascripts/sidebar/sidebar_subscriptions_spec.js38
-rw-r--r--spec/javascripts/sidebar/subscriptions_spec.js100
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js4
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js99
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js13
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js7
-rw-r--r--spec/javascripts/vue_mr_widget/stores/artifacts_list/actions_spec.js165
-rw-r--r--spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js112
-rw-r--r--spec/javascripts/vue_shared/components/ci_badge_link_spec.js100
-rw-r--r--spec/javascripts/vue_shared/components/ci_icon_spec.js122
-rw-r--r--spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js123
-rw-r--r--spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js105
-rw-r--r--spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js81
-rw-r--r--spec/javascripts/vue_shared/components/dropdown/dropdown_hidden_input_spec.js36
-rw-r--r--spec/javascripts/vue_shared/components/file_finder/item_spec.js140
-rw-r--r--spec/javascripts/vue_shared/components/filtered_search_dropdown_spec.js190
-rw-r--r--spec/javascripts/vue_shared/components/gl_countdown_spec.js77
-rw-r--r--spec/javascripts/vue_shared/components/header_ci_component_spec.js93
-rw-r--r--spec/javascripts/vue_shared/components/markdown/suggestions_spec.js106
-rw-r--r--spec/javascripts/vue_shared/components/markdown/toolbar_spec.js40
-rw-r--r--spec/javascripts/vue_shared/components/navigation_tabs_spec.js64
-rw-r--r--spec/javascripts/vue_shared/components/pikaday_spec.js30
-rw-r--r--spec/javascripts/vue_shared/components/project_avatar/default_spec.js58
-rw-r--r--spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js109
-rw-r--r--spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js142
-rw-r--r--spec/javascripts/vue_shared/components/stacked_progress_bar_spec.js107
-rw-r--r--spec/javascripts/vue_shared/components/tabs/tab_spec.js32
-rw-r--r--spec/javascripts/vue_shared/components/tabs/tabs_spec.js68
-rw-r--r--spec/javascripts/vue_shared/components/toggle_button_spec.js101
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar/user_avatar_svg_spec.js29
-rw-r--r--spec/lib/api/entities/branch_spec.rb28
-rw-r--r--spec/lib/api/entities/design_management/design_spec.rb19
-rw-r--r--spec/lib/api/entities/project_repository_storage_move_spec.rb21
-rw-r--r--spec/lib/api/entities/snippet_spec.rb94
-rw-r--r--spec/lib/api/helpers/pagination_strategies_spec.rb77
-rw-r--r--spec/lib/banzai/filter/issue_reference_filter_spec.rb55
-rw-r--r--spec/lib/banzai/filter/upload_link_filter_spec.rb17
-rw-r--r--spec/lib/banzai/filter/user_reference_filter_spec.rb2
-rw-r--r--spec/lib/banzai/pipeline/wiki_pipeline_spec.rb8
-rw-r--r--spec/lib/banzai/reference_parser/design_parser_spec.rb91
-rw-r--r--spec/lib/banzai/renderer_spec.rb57
-rw-r--r--spec/lib/bitbucket_server/representation/activity_spec.rb2
-rw-r--r--spec/lib/bitbucket_server/representation/comment_spec.rb2
-rw-r--r--spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb2
-rw-r--r--spec/lib/bitbucket_server/representation/pull_request_spec.rb2
-rw-r--r--spec/lib/bitbucket_server/representation/repo_spec.rb2
-rw-r--r--spec/lib/container_registry/client_spec.rb52
-rw-r--r--spec/lib/declarative_policy_spec.rb38
-rw-r--r--spec/lib/feature_spec.rb8
-rw-r--r--spec/lib/gitlab/alert_management/alert_params_spec.rb94
-rw-r--r--spec/lib/gitlab/alert_management/alert_status_counts_spec.rb55
-rw-r--r--spec/lib/gitlab/alerting/alert_spec.rb24
-rw-r--r--spec/lib/gitlab/alerting/notification_payload_parser_spec.rb29
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/median_spec.rb42
-rw-r--r--spec/lib/gitlab/app_json_logger_spec.rb4
-rw-r--r--spec/lib/gitlab/application_context_spec.rb14
-rw-r--r--spec/lib/gitlab/auth/auth_finders_spec.rb160
-rw-r--r--spec/lib/gitlab/auth/o_auth/provider_spec.rb12
-rw-r--r--spec/lib/gitlab/auth_spec.rb8
-rw-r--r--spec/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests_spec.rb46
-rw-r--r--spec/lib/gitlab/background_migration/backfill_snippet_repositories_spec.rb187
-rw-r--r--spec/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data_spec.rb73
-rw-r--r--spec/lib/gitlab/chat/responder/mattermost_spec.rb117
-rw-r--r--spec/lib/gitlab/checks/push_file_count_check_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/config/entry/artifacts_spec.rb48
-rw-r--r--spec/lib/gitlab/ci/config/entry/reports_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/entry/trigger_spec.rb13
-rw-r--r--spec/lib/gitlab/ci/cron_parser_spec.rb314
-rw-r--r--spec/lib/gitlab/ci/parsers/accessibility/pa11y_spec.rb118
-rw-r--r--spec/lib/gitlab/ci/parsers/terraform/tfplan_spec.rb51
-rw-r--r--spec/lib/gitlab/ci/parsers/test/junit_spec.rb60
-rw-r--r--spec/lib/gitlab/ci/parsers_spec.rb16
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb10
-rw-r--r--spec/lib/gitlab/ci/reports/accessibility_reports_comparer_spec.rb270
-rw-r--r--spec/lib/gitlab/ci/reports/accessibility_reports_spec.rb232
-rw-r--r--spec/lib/gitlab/ci/reports/terraform_reports_spec.rb34
-rw-r--r--spec/lib/gitlab/ci/reports/test_case_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/reports/test_reports_spec.rb25
-rw-r--r--spec/lib/gitlab/ci/reports/test_suite_spec.rb27
-rw-r--r--spec/lib/gitlab/ci/templates/Jobs/browser_performance_testing_gitlab_ci_yaml_spec.rb85
-rw-r--r--spec/lib/gitlab/ci/templates/Jobs/build_gitlab_ci_yaml_spec.rb58
-rw-r--r--spec/lib/gitlab/ci/templates/Jobs/code_quality_gitlab_ci_yaml_spec.rb86
-rw-r--r--spec/lib/gitlab/ci/templates/Jobs/deploy_gitlab_ci_yaml_spec.rb222
-rw-r--r--spec/lib/gitlab/ci/templates/Jobs/test_gitlab_ci_yaml_spec.rb86
-rw-r--r--spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb100
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb22
-rw-r--r--spec/lib/gitlab/code_navigation_path_spec.rb31
-rw-r--r--spec/lib/gitlab/config_checker/external_database_checker_spec.rb56
-rw-r--r--spec/lib/gitlab/cycle_analytics/group_stage_summary_spec.rb176
-rw-r--r--spec/lib/gitlab/cycle_analytics/summary/value_spec.rb33
-rw-r--r--spec/lib/gitlab/danger/changelog_spec.rb8
-rw-r--r--spec/lib/gitlab/danger/helper_spec.rb65
-rw-r--r--spec/lib/gitlab/danger/teammate_spec.rb13
-rw-r--r--spec/lib/gitlab/database/batch_count_spec.rb8
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb391
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/partitioned_foreign_key_spec.rb48
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers_spec.rb230
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb4
-rw-r--r--spec/lib/gitlab/database/with_lock_retries_spec.rb21
-rw-r--r--spec/lib/gitlab/diff/file_spec.rb55
-rw-r--r--spec/lib/gitlab/diff/formatters/text_formatter_spec.rb18
-rw-r--r--spec/lib/gitlab/diff/position_spec.rb4
-rw-r--r--spec/lib/gitlab/elasticsearch/logs/lines_spec.rb24
-rw-r--r--spec/lib/gitlab/elasticsearch/logs/pods_spec.rb4
-rw-r--r--spec/lib/gitlab/email/handler_spec.rb12
-rw-r--r--spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb23
-rw-r--r--spec/lib/gitlab/email/smime/certificate_spec.rb55
-rw-r--r--spec/lib/gitlab/email/smime/signer_spec.rb35
-rw-r--r--spec/lib/gitlab/exclusive_lease_helpers/sleeping_lock_spec.rb102
-rw-r--r--spec/lib/gitlab/exclusive_lease_helpers_spec.rb31
-rw-r--r--spec/lib/gitlab/exclusive_lease_spec.rb82
-rw-r--r--spec/lib/gitlab/experimentation_spec.rb53
-rw-r--r--spec/lib/gitlab/gfm/reference_rewriter_spec.rb12
-rw-r--r--spec/lib/gitlab/git/attributes_parser_spec.rb8
-rw-r--r--spec/lib/gitlab/git/blob_spec.rb12
-rw-r--r--spec/lib/gitlab/git/commit_spec.rb20
-rw-r--r--spec/lib/gitlab/git/tag_spec.rb30
-rw-r--r--spec/lib/gitlab/git_access_design_spec.rb45
-rw-r--r--spec/lib/gitlab/git_access_snippet_spec.rb153
-rw-r--r--spec/lib/gitlab/git_access_wiki_spec.rb10
-rw-r--r--spec/lib/gitlab/gl_repository/repo_type_spec.rb37
-rw-r--r--spec/lib/gitlab/gl_repository_spec.rb15
-rw-r--r--spec/lib/gitlab/google_code_import/client_spec.rb2
-rw-r--r--spec/lib/gitlab/google_code_import/importer_spec.rb2
-rw-r--r--spec/lib/gitlab/grape_logging/formatters/lograge_with_timestamp_spec.rb2
-rw-r--r--spec/lib/gitlab/grape_logging/loggers/cloudflare_logger_spec.rb31
-rw-r--r--spec/lib/gitlab/grape_logging/loggers/exception_logger_spec.rb69
-rw-r--r--spec/lib/gitlab/graphql/authorize/authorize_field_service_spec.rb10
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb15
-rw-r--r--spec/lib/gitlab/graphql_logger_spec.rb6
-rw-r--r--spec/lib/gitlab/health_checks/master_check_spec.rb5
-rw-r--r--spec/lib/gitlab/hook_data/issuable_builder_spec.rb1
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml22
-rw-r--r--spec/lib/gitlab/import_export/attribute_configuration_spec.rb3
-rw-r--r--spec/lib/gitlab/import_export/design_repo_restorer_spec.rb42
-rw-r--r--spec/lib/gitlab/import_export/design_repo_saver_spec.rb37
-rw-r--r--spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/group/legacy_tree_restorer_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/group/tree_restorer_spec.rb184
-rw-r--r--spec/lib/gitlab/import_export/group/tree_saver_spec.rb140
-rw-r--r--spec/lib/gitlab/import_export/import_export_equivalence_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/import_test_coverage_spec.rb13
-rw-r--r--spec/lib/gitlab/import_export/importer_spec.rb79
-rw-r--r--spec/lib/gitlab/import_export/json/legacy_reader/file_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/json/legacy_reader/hash_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb14
-rw-r--r--spec/lib/gitlab/import_export/lfs_saver_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/project/export_task_spec.rb43
-rw-r--r--spec/lib/gitlab/import_export/project/import_task_spec.rb49
-rw-r--r--spec/lib/gitlab/import_export/project/tree_restorer_spec.rb108
-rw-r--r--spec/lib/gitlab/import_export/project/tree_saver_spec.rb25
-rw-r--r--spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb14
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml2
-rw-r--r--spec/lib/gitlab/instrumentation_helper_spec.rb6
-rw-r--r--spec/lib/gitlab/jira_import/base_importer_spec.rb20
-rw-r--r--spec/lib/gitlab/jira_import/handle_labels_service_spec.rb53
-rw-r--r--spec/lib/gitlab/jira_import/issue_serializer_spec.rb150
-rw-r--r--spec/lib/gitlab/jira_import/issues_importer_spec.rb36
-rw-r--r--spec/lib/gitlab/jira_import/labels_importer_spec.rb83
-rw-r--r--spec/lib/gitlab/jira_import/metadata_collector_spec.rb178
-rw-r--r--spec/lib/gitlab/jira_import/user_mapper_spec.rb80
-rw-r--r--spec/lib/gitlab/json_logger_spec.rb4
-rw-r--r--spec/lib/gitlab/json_spec.rb152
-rw-r--r--spec/lib/gitlab/kubernetes/helm/api_spec.rb25
-rw-r--r--spec/lib/gitlab/kubernetes/helm/base_command_spec.rb80
-rw-r--r--spec/lib/gitlab/kubernetes/helm/delete_command_spec.rb41
-rw-r--r--spec/lib/gitlab/kubernetes/helm/init_command_spec.rb73
-rw-r--r--spec/lib/gitlab/kubernetes/helm/install_command_spec.rb84
-rw-r--r--spec/lib/gitlab/kubernetes/helm/parsers/list_v2_spec.rb100
-rw-r--r--spec/lib/gitlab/kubernetes/helm/patch_command_spec.rb68
-rw-r--r--spec/lib/gitlab/kubernetes/helm/pod_spec.rb2
-rw-r--r--spec/lib/gitlab/kubernetes/helm/reset_command_spec.rb33
-rw-r--r--spec/lib/gitlab/kubernetes/kube_client_spec.rb108
-rw-r--r--spec/lib/gitlab/kubernetes/network_policy_spec.rb224
-rw-r--r--spec/lib/gitlab/legacy_github_import/importer_spec.rb1
-rw-r--r--spec/lib/gitlab/logging/cloudflare_helper_spec.rb52
-rw-r--r--spec/lib/gitlab/lograge/custom_options_spec.rb33
-rw-r--r--spec/lib/gitlab/mail_room/mail_room_spec.rb3
-rw-r--r--spec/lib/gitlab/metrics/background_transaction_spec.rb6
-rw-r--r--spec/lib/gitlab/metrics/dashboard/stages/grafana_formatter_spec.rb6
-rw-r--r--spec/lib/gitlab/metrics/dashboard/url_spec.rb32
-rw-r--r--spec/lib/gitlab/metrics/exporter/sidekiq_exporter_spec.rb2
-rw-r--r--spec/lib/gitlab/metrics/method_call_spec.rb19
-rw-r--r--spec/lib/gitlab/metrics/metric_spec.rb71
-rw-r--r--spec/lib/gitlab/metrics/rack_middleware_spec.rb25
-rw-r--r--spec/lib/gitlab/metrics/samplers/database_sampler_spec.rb49
-rw-r--r--spec/lib/gitlab/metrics/samplers/influx_sampler_spec.rb105
-rw-r--r--spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb32
-rw-r--r--spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb7
-rw-r--r--spec/lib/gitlab/metrics/subscribers/action_view_spec.rb6
-rw-r--r--spec/lib/gitlab/metrics/system_spec.rb117
-rw-r--r--spec/lib/gitlab/metrics/transaction_spec.rb165
-rw-r--r--spec/lib/gitlab/metrics/web_transaction_spec.rb144
-rw-r--r--spec/lib/gitlab/metrics_spec.rb78
-rw-r--r--spec/lib/gitlab/middleware/multipart_spec.rb11
-rw-r--r--spec/lib/gitlab/omniauth_initializer_spec.rb16
-rw-r--r--spec/lib/gitlab/pagination/keyset_spec.rb12
-rw-r--r--spec/lib/gitlab/path_regex_spec.rb15
-rw-r--r--spec/lib/gitlab/performance_bar_spec.rb59
-rw-r--r--spec/lib/gitlab/phabricator_import/conduit/response_spec.rb4
-rw-r--r--spec/lib/gitlab/phabricator_import/conduit/tasks_response_spec.rb2
-rw-r--r--spec/lib/gitlab/phabricator_import/conduit/users_response_spec.rb2
-rw-r--r--spec/lib/gitlab/phabricator_import/issues/importer_spec.rb2
-rw-r--r--spec/lib/gitlab/project_search_results_spec.rb113
-rw-r--r--spec/lib/gitlab/prometheus_client_spec.rb4
-rw-r--r--spec/lib/gitlab/regex_spec.rb33
-rw-r--r--spec/lib/gitlab/repository_url_builder_spec.rb2
-rw-r--r--spec/lib/gitlab/request_context_spec.rb4
-rw-r--r--spec/lib/gitlab/runtime_spec.rb13
-rw-r--r--spec/lib/gitlab/search_results_spec.rb10
-rw-r--r--spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb8
-rw-r--r--spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb27
-rw-r--r--spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb25
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb19
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/extra_done_log_metadata_spec.rb35
-rw-r--r--spec/lib/gitlab/sidekiq_middleware_spec.rb24
-rw-r--r--spec/lib/gitlab/snippet_search_results_spec.rb29
-rw-r--r--spec/lib/gitlab/static_site_editor/config_spec.rb28
-rw-r--r--spec/lib/gitlab/throttle_spec.rb78
-rw-r--r--spec/lib/gitlab/tracking_spec.rb8
-rw-r--r--spec/lib/gitlab/tree_summary_spec.rb16
-rw-r--r--spec/lib/gitlab/url_builder_spec.rb13
-rw-r--r--spec/lib/gitlab/usage_data_counters/designs_counter_spec.rb14
-rw-r--r--spec/lib/gitlab/usage_data_counters/web_ide_counter_spec.rb47
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb344
-rw-r--r--spec/lib/gitlab/user_access_snippet_spec.rb52
-rw-r--r--spec/lib/gitlab/utils/measuring_spec.rb40
-rw-r--r--spec/lib/gitlab/utils_spec.rb34
-rw-r--r--spec/lib/gitlab/view/presenter/factory_spec.rb6
-rw-r--r--spec/lib/gitlab/wiki_pages/front_matter_parser_spec.rb6
-rw-r--r--spec/lib/gitlab/with_request_store_spec.rb30
-rw-r--r--spec/lib/gitlab/workhorse_spec.rb88
-rw-r--r--spec/lib/gitlab/x509/signature_spec.rb160
-rw-r--r--spec/lib/gitlab/x509/tag_spec.rb42
-rw-r--r--spec/lib/gitlab_danger_spec.rb2
-rw-r--r--spec/lib/google_api/auth_spec.rb14
-rw-r--r--spec/lib/grafana/validator_spec.rb4
-rw-r--r--spec/lib/omni_auth/strategies/jwt_spec.rb4
-rw-r--r--spec/lib/quality/helm_client_spec.rb132
-rw-r--r--spec/lib/quality/test_level_spec.rb4
-rw-r--r--spec/lib/rspec_flaky/flaky_example_spec.rb2
-rw-r--r--spec/lib/rspec_flaky/report_spec.rb6
-rw-r--r--spec/lib/sentry/client/event_spec.rb2
-rw-r--r--spec/lib/sentry/client/issue_link_spec.rb4
-rw-r--r--spec/lib/sentry/client/issue_spec.rb4
-rw-r--r--spec/lib/sentry/client/projects_spec.rb2
-rw-r--r--spec/lib/sentry/client/repo_spec.rb2
-rw-r--r--spec/lib/serializers/json_spec.rb2
-rw-r--r--spec/lib/system_check/app/hashed_storage_all_projects_check_spec.rb24
-rw-r--r--spec/lib/system_check/app/hashed_storage_enabled_check_spec.rb24
-rw-r--r--spec/lib/system_check/simple_executor_spec.rb140
-rw-r--r--spec/lib/system_check_spec.rb21
-rw-r--r--spec/mailers/emails/groups_spec.rb41
-rw-r--r--spec/mailers/emails/profile_spec.rb40
-rw-r--r--spec/mailers/notify_spec.rb43
-rw-r--r--spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb34
-rw-r--r--spec/migrations/backfill_snippet_repositories_spec.rb44
-rw-r--r--spec/migrations/cleanup_optimistic_locking_nulls_pt2_fixed_spec.rb45
-rw-r--r--spec/migrations/cleanup_optimistic_locking_nulls_spec.rb9
-rw-r--r--spec/migrations/cleanup_projects_with_missing_namespace_spec.rb134
-rw-r--r--spec/migrations/encrypt_plaintext_attributes_on_application_settings_spec.rb10
-rw-r--r--spec/migrations/fill_file_store_ci_job_artifacts_spec.rb44
-rw-r--r--spec/migrations/fill_file_store_lfs_objects_spec.rb36
-rw-r--r--spec/migrations/fill_store_uploads_spec.rb48
-rw-r--r--spec/migrations/remove_additional_application_settings_rows_spec.rb27
-rw-r--r--spec/migrations/remove_deprecated_jenkins_service_records_spec.rb28
-rw-r--r--spec/migrations/remove_orphaned_invited_members_spec.rb55
-rw-r--r--spec/models/ability_spec.rb46
-rw-r--r--spec/models/alert_management/alert_spec.rb320
-rw-r--r--spec/models/application_setting_spec.rb14
-rw-r--r--spec/models/blob_spec.rb512
-rw-r--r--spec/models/blob_viewer/readme_spec.rb2
-rw-r--r--spec/models/broadcast_message_spec.rb18
-rw-r--r--spec/models/ci/build_spec.rb353
-rw-r--r--spec/models/ci/daily_build_group_report_result_spec.rb59
-rw-r--r--spec/models/ci/daily_report_result_spec.rb62
-rw-r--r--spec/models/ci/freeze_period_spec.rb50
-rw-r--r--spec/models/ci/freeze_period_status_spec.rb62
-rw-r--r--spec/models/ci/instance_variable_spec.rb93
-rw-r--r--spec/models/ci/job_artifact_spec.rb120
-rw-r--r--spec/models/ci/persistent_ref_spec.rb12
-rw-r--r--spec/models/ci/pipeline_schedule_spec.rb8
-rw-r--r--spec/models/ci/pipeline_spec.rb145
-rw-r--r--spec/models/ci/processable_spec.rb159
-rw-r--r--spec/models/ci/runner_spec.rb8
-rw-r--r--spec/models/ci/stage_spec.rb26
-rw-r--r--spec/models/clusters/applications/elastic_stack_spec.rb70
-rw-r--r--spec/models/clusters/applications/fluentd_spec.rb36
-rw-r--r--spec/models/clusters/applications/ingress_spec.rb6
-rw-r--r--spec/models/clusters/applications/jupyter_spec.rb4
-rw-r--r--spec/models/clusters/cluster_spec.rb98
-rw-r--r--spec/models/commit_status_spec.rb44
-rw-r--r--spec/models/concerns/awardable_spec.rb41
-rw-r--r--spec/models/concerns/blocks_json_serialization_spec.rb7
-rw-r--r--spec/models/concerns/cache_markdown_field_spec.rb52
-rw-r--r--spec/models/concerns/cacheable_attributes_spec.rb4
-rw-r--r--spec/models/concerns/has_user_type_spec.rb86
-rw-r--r--spec/models/concerns/mentionable_spec.rb52
-rw-r--r--spec/models/concerns/noteable_spec.rb2
-rw-r--r--spec/models/concerns/reactive_caching_spec.rb100
-rw-r--r--spec/models/concerns/redis_cacheable_spec.rb6
-rw-r--r--spec/models/concerns/spammable_spec.rb91
-rw-r--r--spec/models/container_repository_spec.rb12
-rw-r--r--spec/models/cycle_analytics/code_spec.rb2
-rw-r--r--spec/models/cycle_analytics/group_level_spec.rb44
-rw-r--r--spec/models/cycle_analytics/issue_spec.rb2
-rw-r--r--spec/models/cycle_analytics/plan_spec.rb2
-rw-r--r--spec/models/cycle_analytics/production_spec.rb2
-rw-r--r--spec/models/cycle_analytics/project_level_spec.rb2
-rw-r--r--spec/models/cycle_analytics/review_spec.rb2
-rw-r--r--spec/models/cycle_analytics/staging_spec.rb2
-rw-r--r--spec/models/cycle_analytics/test_spec.rb2
-rw-r--r--spec/models/deploy_token_spec.rb4
-rw-r--r--spec/models/design_management/action_spec.rb105
-rw-r--r--spec/models/design_management/design_action_spec.rb98
-rw-r--r--spec/models/design_management/design_at_version_spec.rb426
-rw-r--r--spec/models/design_management/design_collection_spec.rb82
-rw-r--r--spec/models/design_management/design_spec.rb575
-rw-r--r--spec/models/design_management/repository_spec.rb58
-rw-r--r--spec/models/design_management/version_spec.rb342
-rw-r--r--spec/models/design_user_mention_spec.rb12
-rw-r--r--spec/models/diff_note_spec.rb18
-rw-r--r--spec/models/email_spec.rb20
-rw-r--r--spec/models/environment_spec.rb21
-rw-r--r--spec/models/event_spec.rb171
-rw-r--r--spec/models/group_spec.rb168
-rw-r--r--spec/models/hooks/project_hook_spec.rb4
-rw-r--r--spec/models/issue_spec.rb132
-rw-r--r--spec/models/iteration_spec.rb170
-rw-r--r--spec/models/jira_import_state_spec.rb1
-rw-r--r--spec/models/member_spec.rb22
-rw-r--r--spec/models/merge_request_diff_spec.rb59
-rw-r--r--spec/models/merge_request_spec.rb168
-rw-r--r--spec/models/metrics/users_starred_dashboard_spec.rb39
-rw-r--r--spec/models/milestone_note_spec.rb10
-rw-r--r--spec/models/milestone_spec.rb163
-rw-r--r--spec/models/namespace/root_storage_size_spec.rb67
-rw-r--r--spec/models/note_spec.rb40
-rw-r--r--spec/models/pages_domain_spec.rb6
-rw-r--r--spec/models/performance_monitoring/prometheus_dashboard_spec.rb102
-rw-r--r--spec/models/performance_monitoring/prometheus_metric_spec.rb59
-rw-r--r--spec/models/performance_monitoring/prometheus_panel_group_spec.rb54
-rw-r--r--spec/models/performance_monitoring/prometheus_panel_spec.rb77
-rw-r--r--spec/models/personal_access_token_spec.rb23
-rw-r--r--spec/models/personal_snippet_spec.rb1
-rw-r--r--spec/models/plan_limits_spec.rb74
-rw-r--r--spec/models/plan_spec.rb17
-rw-r--r--spec/models/project_ci_cd_setting_spec.rb12
-rw-r--r--spec/models/project_feature_spec.rb74
-rw-r--r--spec/models/project_repository_storage_move_spec.rb63
-rw-r--r--spec/models/project_services/chat_message/pipeline_message_spec.rb605
-rw-r--r--spec/models/project_services/irker_service_spec.rb2
-rw-r--r--spec/models/project_services/jira_service_spec.rb93
-rw-r--r--spec/models/project_services/mattermost_slash_commands_service_spec.rb7
-rw-r--r--spec/models/project_services/microsoft_teams_service_spec.rb2
-rw-r--r--spec/models/project_services/webex_teams_service_spec.rb10
-rw-r--r--spec/models/project_snippet_spec.rb1
-rw-r--r--spec/models/project_spec.rb279
-rw-r--r--spec/models/project_wiki_spec.rb453
-rw-r--r--spec/models/release_spec.rb52
-rw-r--r--spec/models/remote_mirror_spec.rb52
-rw-r--r--spec/models/repository_spec.rb76
-rw-r--r--spec/models/resource_label_event_spec.rb3
-rw-r--r--spec/models/resource_milestone_event_spec.rb17
-rw-r--r--spec/models/resource_state_event_spec.rb14
-rw-r--r--spec/models/sent_notification_spec.rb22
-rw-r--r--spec/models/service_spec.rb32
-rw-r--r--spec/models/snippet_repository_spec.rb32
-rw-r--r--spec/models/snippet_spec.rb54
-rw-r--r--spec/models/spam_log_spec.rb27
-rw-r--r--spec/models/state_note_spec.rb29
-rw-r--r--spec/models/timelog_spec.rb6
-rw-r--r--spec/models/todo_spec.rb32
-rw-r--r--spec/models/tree_spec.rb21
-rw-r--r--spec/models/user_spec.rb165
-rw-r--r--spec/models/user_type_enums_spec.rb13
-rw-r--r--spec/models/wiki_page/meta_spec.rb87
-rw-r--r--spec/models/wiki_page_spec.rb161
-rw-r--r--spec/models/x509_commit_signature_spec.rb32
-rw-r--r--spec/policies/alert_management/alert_policy_spec.rb25
-rw-r--r--spec/policies/base_policy_spec.rb2
-rw-r--r--spec/policies/blob_policy_spec.rb2
-rw-r--r--spec/policies/ci/build_policy_spec.rb18
-rw-r--r--spec/policies/clusters/cluster_policy_spec.rb11
-rw-r--r--spec/policies/clusters/instance_policy_spec.rb20
-rw-r--r--spec/policies/deploy_key_policy_spec.rb18
-rw-r--r--spec/policies/design_management/design_policy_spec.rb181
-rw-r--r--spec/policies/environment_policy_spec.rb32
-rw-r--r--spec/policies/global_policy_spec.rb78
-rw-r--r--spec/policies/group_policy_spec.rb30
-rw-r--r--spec/policies/issue_policy_spec.rb22
-rw-r--r--spec/policies/merge_request_policy_spec.rb6
-rw-r--r--spec/policies/namespace_policy_spec.rb8
-rw-r--r--spec/policies/note_policy_spec.rb12
-rw-r--r--spec/policies/personal_snippet_policy_spec.rb10
-rw-r--r--spec/policies/project_policy_spec.rb369
-rw-r--r--spec/policies/project_snippet_policy_spec.rb15
-rw-r--r--spec/policies/user_policy_spec.rb8
-rw-r--r--spec/policies/wiki_page_policy_spec.rb2
-rw-r--r--spec/presenters/ci/build_presenter_spec.rb26
-rw-r--r--spec/presenters/ci/build_runner_presenter_spec.rb142
-rw-r--r--spec/presenters/clusterable_presenter_spec.rb16
-rw-r--r--spec/presenters/pages_domain_presenter_spec.rb8
-rw-r--r--spec/presenters/projects/prometheus/alert_presenter_spec.rb142
-rw-r--r--spec/requests/api/admin/ci/variables_spec.rb210
-rw-r--r--spec/requests/api/appearance_spec.rb5
-rw-r--r--spec/requests/api/branches_spec.rb9
-rw-r--r--spec/requests/api/deployments_spec.rb2
-rw-r--r--spec/requests/api/features_spec.rb36
-rw-r--r--spec/requests/api/freeze_periods_spec.rb475
-rw-r--r--spec/requests/api/graphql/boards/board_lists_query_spec.rb137
-rw-r--r--spec/requests/api/graphql/current_user/todos_query_spec.rb7
-rw-r--r--spec/requests/api/graphql/gitlab_schema_spec.rb2
-rw-r--r--spec/requests/api/graphql/group/milestones_spec.rb48
-rw-r--r--spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb72
-rw-r--r--spec/requests/api/graphql/mutations/alert_management/alerts/update_alert_status_spec.rb42
-rw-r--r--spec/requests/api/graphql/mutations/award_emojis/add_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/branches/create_spec.rb45
-rw-r--r--spec/requests/api/graphql/mutations/design_management/delete_spec.rb127
-rw-r--r--spec/requests/api/graphql/mutations/design_management/upload_spec.rb99
-rw-r--r--spec/requests/api/graphql/mutations/jira_import/start_spec.rb5
-rw-r--r--spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb231
-rw-r--r--spec/requests/api/graphql/mutations/snippets/create_spec.rb52
-rw-r--r--spec/requests/api/graphql/mutations/snippets/destroy_spec.rb13
-rw-r--r--spec/requests/api/graphql/mutations/snippets/mark_as_spam_spec.rb8
-rw-r--r--spec/requests/api/graphql/mutations/snippets/update_spec.rb25
-rw-r--r--spec/requests/api/graphql/project/alert_management/alert_status_counts_spec.rb61
-rw-r--r--spec/requests/api/graphql/project/alert_management/alerts_spec.rb139
-rw-r--r--spec/requests/api/graphql/project/grafana_integration_spec.rb10
-rw-r--r--spec/requests/api/graphql/project/issue/design_collection/version_spec.rb216
-rw-r--r--spec/requests/api/graphql/project/issue/design_collection/versions_spec.rb113
-rw-r--r--spec/requests/api/graphql/project/issue/designs/designs_spec.rb388
-rw-r--r--spec/requests/api/graphql/project/issue/designs/notes_spec.rb70
-rw-r--r--spec/requests/api/graphql/project/issue_spec.rb189
-rw-r--r--spec/requests/api/graphql/project/issues_spec.rb209
-rw-r--r--spec/requests/api/graphql/project/jira_import_spec.rb3
-rw-r--r--spec/requests/api/graphql/query_spec.rb95
-rw-r--r--spec/requests/api/graphql_spec.rb2
-rw-r--r--spec/requests/api/groups_spec.rb271
-rw-r--r--spec/requests/api/helpers_spec.rb4
-rw-r--r--spec/requests/api/internal/base_spec.rb95
-rw-r--r--spec/requests/api/issues/get_group_issues_spec.rb51
-rw-r--r--spec/requests/api/issues/issues_spec.rb26
-rw-r--r--spec/requests/api/issues/post_projects_issues_spec.rb2
-rw-r--r--spec/requests/api/issues/put_projects_issues_spec.rb9
-rw-r--r--spec/requests/api/merge_requests_spec.rb68
-rw-r--r--spec/requests/api/metrics/dashboard/annotations_spec.rb140
-rw-r--r--spec/requests/api/metrics/user_starred_dashboards_spec.rb164
-rw-r--r--spec/requests/api/pipeline_schedules_spec.rb2
-rw-r--r--spec/requests/api/pipelines_spec.rb99
-rw-r--r--spec/requests/api/project_export_spec.rb4
-rw-r--r--spec/requests/api/project_milestones_spec.rb8
-rw-r--r--spec/requests/api/project_repository_storage_moves_spec.rb89
-rw-r--r--spec/requests/api/project_snippets_spec.rb98
-rw-r--r--spec/requests/api/project_statistics_spec.rb30
-rw-r--r--spec/requests/api/project_templates_spec.rb28
-rw-r--r--spec/requests/api/projects_spec.rb6
-rw-r--r--spec/requests/api/remote_mirrors_spec.rb23
-rw-r--r--spec/requests/api/runner_spec.rb58
-rw-r--r--spec/requests/api/runners_spec.rb93
-rw-r--r--spec/requests/api/search_spec.rb252
-rw-r--r--spec/requests/api/settings_spec.rb14
-rw-r--r--spec/requests/api/snippets_spec.rb136
-rw-r--r--spec/requests/api/statistics_spec.rb4
-rw-r--r--spec/requests/api/terraform/state_spec.rb16
-rw-r--r--spec/requests/api/todos_spec.rb41
-rw-r--r--spec/requests/api/users_spec.rb6
-rw-r--r--spec/requests/api/wikis_spec.rb4
-rw-r--r--spec/requests/jwt_controller_spec.rb42
-rw-r--r--spec/requests/rack_attack_global_spec.rb57
-rw-r--r--spec/requests/user_activity_spec.rb4
-rw-r--r--spec/routing/admin_routing_spec.rb7
-rw-r--r--spec/routing/project_routing_spec.rb55
-rw-r--r--spec/routing/routing_spec.rb24
-rw-r--r--spec/rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers_spec.rb49
-rw-r--r--spec/rubocop/cop/gitlab/change_timezone_spec.rb21
-rw-r--r--spec/rubocop/cop/gitlab/json_spec.rb39
-rw-r--r--spec/rubocop/cop/inject_enterprise_edition_module_spec.rb11
-rw-r--r--spec/rubocop/cop/migration/add_column_with_default_spec.rb45
-rw-r--r--spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb4
-rw-r--r--spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb6
-rw-r--r--spec/rubocop/cop/migration/add_limit_to_string_columns_spec.rb268
-rw-r--r--spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb160
-rw-r--r--spec/rubocop/cop/migration/prevent_strings_spec.rb143
-rw-r--r--spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb43
-rw-r--r--spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb68
-rw-r--r--spec/rubocop/cop/migration/with_lock_retries_without_ddl_transaction_spec.rb46
-rw-r--r--spec/rubocop/cop/performance/ar_exists_and_present_blank_spec.rb111
-rw-r--r--spec/rubocop/cop/rspec/empty_line_after_shared_example_spec.rb86
-rw-r--r--spec/rubocop/cop/rspec/env_assignment_spec.rb12
-rw-r--r--spec/serializers/accessibility_error_entity_spec.rb37
-rw-r--r--spec/serializers/accessibility_reports_comparer_entity_spec.rb87
-rw-r--r--spec/serializers/accessibility_reports_comparer_serializer_spec.rb65
-rw-r--r--spec/serializers/ci/dag_job_entity_spec.rb43
-rw-r--r--spec/serializers/ci/dag_job_group_entity_spec.rb58
-rw-r--r--spec/serializers/ci/dag_pipeline_entity_spec.rb112
-rw-r--r--spec/serializers/ci/dag_pipeline_serializer_spec.rb17
-rw-r--r--spec/serializers/ci/dag_stage_entity_spec.rb31
-rw-r--r--spec/serializers/cluster_application_entity_spec.rb12
-rw-r--r--spec/serializers/cluster_entity_spec.rb6
-rw-r--r--spec/serializers/cluster_serializer_spec.rb38
-rw-r--r--spec/serializers/diff_file_base_entity_spec.rb58
-rw-r--r--spec/serializers/diffs_metadata_entity_spec.rb2
-rw-r--r--spec/serializers/environment_entity_spec.rb50
-rw-r--r--spec/serializers/merge_request_poll_widget_entity_spec.rb44
-rw-r--r--spec/serializers/merge_request_sidebar_basic_entity_spec.rb2
-rw-r--r--spec/serializers/service_event_entity_spec.rb41
-rw-r--r--spec/serializers/test_case_entity_spec.rb4
-rw-r--r--spec/serializers/test_suite_entity_spec.rb50
-rw-r--r--spec/services/alert_management/create_alert_issue_service_spec.rb152
-rw-r--r--spec/services/alert_management/process_prometheus_alert_service_spec.rb136
-rw-r--r--spec/services/alert_management/update_alert_status_service_spec.rb66
-rw-r--r--spec/services/application_settings/update_service_spec.rb2
-rw-r--r--spec/services/auth/container_registry_authentication_service_spec.rb20
-rw-r--r--spec/services/authorized_project_update/project_create_service_spec.rb142
-rw-r--r--spec/services/base_container_service_spec.rb23
-rw-r--r--spec/services/boards/issues/list_service_spec.rb2
-rw-r--r--spec/services/branches/create_service_spec.rb30
-rw-r--r--spec/services/ci/compare_accessibility_reports_service_spec.rb62
-rw-r--r--spec/services/ci/compare_test_reports_service_spec.rb7
-rw-r--r--spec/services/ci/create_job_artifacts_service_spec.rb67
-rw-r--r--spec/services/ci/create_pipeline_service/custom_config_content_spec.rb4
-rw-r--r--spec/services/ci/daily_build_group_report_result_service_spec.rb158
-rw-r--r--spec/services/ci/daily_report_result_service_spec.rb161
-rw-r--r--spec/services/ci/destroy_expired_job_artifacts_service_spec.rb26
-rw-r--r--spec/services/ci/generate_terraform_reports_service_spec.rb71
-rw-r--r--spec/services/ci/pipeline_processing/atomic_processing_service/status_collection_spec.rb15
-rw-r--r--spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb6
-rw-r--r--spec/services/ci/pipeline_processing/legacy_processing_service_spec.rb19
-rw-r--r--spec/services/ci/pipeline_processing/shared_processing_service.rb25
-rw-r--r--spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb57
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_allow_failure_test_on_failure.yml47
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_fails.yml39
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_fails_deploy_needs_test.yml39
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_fails_deploy_needs_test_when_always.yml43
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_fails_other_build_succeeds.yml62
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_fails_other_build_succeeds_deploy_always.yml63
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_fails_test_allow_failure.yml40
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_fails_test_always.yml35
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_fails_test_on_failure.yml35
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_succeeds_test_on_failure.yml35
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_builds_succeed_test_on_failure.yml63
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_builds_succeed_test_on_failure_deploy_always.yml64
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_test_allow_failure_true.yml43
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_false.yml66
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true.yml58
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_always.yml27
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_on_failure.yml48
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_other_test_succeeds.yml42
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_test_on_failure_with_failure.yml66
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_test_on_failure_with_success.yml40
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/stage_build_allow_failure_test_on_failure.yml53
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/stage_build_fails.yml38
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/stage_build_fails_test_allow_failure.yml39
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/stage_test_manual_allow_failure_false.yml65
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/stage_test_manual_allow_failure_true.yml54
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/stage_test_manual_allow_failure_true_deploy_on_failure.yml44
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/stage_test_on_failure_with_failure.yml52
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/stage_test_on_failure_with_success.yml52
-rw-r--r--spec/services/ci/pipeline_schedule_service_spec.rb32
-rw-r--r--spec/services/ci/process_pipeline_service_spec.rb19
-rw-r--r--spec/services/ci/register_job_service_spec.rb2
-rw-r--r--spec/services/ci/retry_build_service_spec.rb50
-rw-r--r--spec/services/ci/retry_pipeline_service_spec.rb19
-rw-r--r--spec/services/ci/update_instance_variables_service_spec.rb230
-rw-r--r--spec/services/clusters/applications/check_upgrade_progress_service_spec.rb4
-rw-r--r--spec/services/clusters/applications/ingress_modsecurity_usage_service_spec.rb196
-rw-r--r--spec/services/clusters/applications/schedule_update_service_spec.rb6
-rw-r--r--spec/services/clusters/gcp/finalize_creation_service_spec.rb3
-rw-r--r--spec/services/clusters/kubernetes/configure_istio_ingress_service_spec.rb4
-rw-r--r--spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb1
-rw-r--r--spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb12
-rw-r--r--spec/services/clusters/parse_cluster_applications_artifact_service_spec.rb200
-rw-r--r--spec/services/cohorts_service_spec.rb2
-rw-r--r--spec/services/deployments/older_deployments_drop_service_spec.rb37
-rw-r--r--spec/services/design_management/delete_designs_service_spec.rb195
-rw-r--r--spec/services/design_management/design_user_notes_count_service_spec.rb43
-rw-r--r--spec/services/design_management/generate_image_versions_service_spec.rb77
-rw-r--r--spec/services/design_management/save_designs_service_spec.rb356
-rw-r--r--spec/services/emails/confirm_service_spec.rb6
-rw-r--r--spec/services/event_create_service_spec.rb13
-rw-r--r--spec/services/git/branch_push_service_spec.rb10
-rw-r--r--spec/services/git/wiki_push_service/change_spec.rb109
-rw-r--r--spec/services/git/wiki_push_service_spec.rb338
-rw-r--r--spec/services/grafana/proxy_service_spec.rb2
-rw-r--r--spec/services/groups/create_service_spec.rb21
-rw-r--r--spec/services/groups/import_export/export_service_spec.rb40
-rw-r--r--spec/services/groups/import_export/import_service_spec.rb254
-rw-r--r--spec/services/groups/update_service_spec.rb20
-rw-r--r--spec/services/incident_management/create_issue_service_spec.rb24
-rw-r--r--spec/services/issuable/clone/attributes_rewriter_spec.rb28
-rw-r--r--spec/services/issues/close_service_spec.rb2
-rw-r--r--spec/services/issues/create_service_spec.rb86
-rw-r--r--spec/services/issues/related_branches_service_spec.rb102
-rw-r--r--spec/services/issues/resolve_discussions_spec.rb19
-rw-r--r--spec/services/issues/update_service_spec.rb30
-rw-r--r--spec/services/jira_import/start_import_service_spec.rb122
-rw-r--r--spec/services/lfs/file_transformer_spec.rb17
-rw-r--r--spec/services/merge_requests/create_service_spec.rb13
-rw-r--r--spec/services/merge_requests/merge_service_spec.rb2
-rw-r--r--spec/services/merge_requests/rebase_service_spec.rb19
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb40
-rw-r--r--spec/services/merge_requests/squash_service_spec.rb40
-rw-r--r--spec/services/merge_requests/update_service_spec.rb3
-rw-r--r--spec/services/metrics/dashboard/clone_dashboard_service_spec.rb4
-rw-r--r--spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb4
-rw-r--r--spec/services/metrics/dashboard/transient_embed_service_spec.rb6
-rw-r--r--spec/services/metrics/users_starred_dashboards/create_service_spec.rb72
-rw-r--r--spec/services/metrics/users_starred_dashboards/delete_service_spec.rb41
-rw-r--r--spec/services/namespaces/check_storage_size_service_spec.rb159
-rw-r--r--spec/services/note_summary_spec.rb6
-rw-r--r--spec/services/notes/create_service_spec.rb56
-rw-r--r--spec/services/notes/post_process_service_spec.rb27
-rw-r--r--spec/services/notification_service_spec.rb66
-rw-r--r--spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb2
-rw-r--r--spec/services/pod_logs/base_service_spec.rb30
-rw-r--r--spec/services/pod_logs/elasticsearch_service_spec.rb32
-rw-r--r--spec/services/pod_logs/kubernetes_service_spec.rb20
-rw-r--r--spec/services/post_receive_service_spec.rb35
-rw-r--r--spec/services/projects/alerting/notify_service_spec.rb96
-rw-r--r--spec/services/projects/create_service_spec.rb98
-rw-r--r--spec/services/projects/fork_service_spec.rb8
-rw-r--r--spec/services/projects/hashed_storage/base_attachment_service_spec.rb2
-rw-r--r--spec/services/projects/hashed_storage/migrate_repository_service_spec.rb10
-rw-r--r--spec/services/projects/hashed_storage/rollback_repository_service_spec.rb8
-rw-r--r--spec/services/projects/import_export/export_service_spec.rb28
-rw-r--r--spec/services/projects/import_service_spec.rb22
-rw-r--r--spec/services/projects/prometheus/alerts/create_events_service_spec.rb6
-rw-r--r--spec/services/projects/prometheus/alerts/notify_service_spec.rb33
-rw-r--r--spec/services/projects/propagate_service_template_spec.rb36
-rw-r--r--spec/services/projects/transfer_service_spec.rb271
-rw-r--r--spec/services/projects/update_remote_mirror_service_spec.rb37
-rw-r--r--spec/services/projects/update_repository_storage_service_spec.rb46
-rw-r--r--spec/services/prometheus/proxy_service_spec.rb2
-rw-r--r--spec/services/prometheus/proxy_variable_substitution_service_spec.rb156
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb4
-rw-r--r--spec/services/releases/create_service_spec.rb3
-rw-r--r--spec/services/repository_archive_clean_up_service_spec.rb2
-rw-r--r--spec/services/resource_access_tokens/create_service_spec.rb163
-rw-r--r--spec/services/resource_access_tokens/revoke_service_spec.rb111
-rw-r--r--spec/services/resource_events/change_milestone_service_spec.rb10
-rw-r--r--spec/services/resource_events/merge_into_notes_service_spec.rb2
-rw-r--r--spec/services/resources/create_access_token_service_spec.rb163
-rw-r--r--spec/services/search/snippet_service_spec.rb50
-rw-r--r--spec/services/search_service_spec.rb86
-rw-r--r--spec/services/snippets/create_service_spec.rb154
-rw-r--r--spec/services/snippets/update_service_spec.rb90
-rw-r--r--spec/services/spam/spam_action_service_spec.rb215
-rw-r--r--spec/services/spam/spam_check_service_spec.rb170
-rw-r--r--spec/services/spam/spam_verdict_service_spec.rb65
-rw-r--r--spec/services/system_note_service_spec.rb28
-rw-r--r--spec/services/system_notes/design_management_service_spec.rb155
-rw-r--r--spec/services/template_engines/liquid_service_spec.rb126
-rw-r--r--spec/services/todo_service_spec.rb30
-rw-r--r--spec/services/update_merge_request_metrics_service_spec.rb4
-rw-r--r--spec/services/user_project_access_changed_service_spec.rb9
-rw-r--r--spec/services/users/destroy_service_spec.rb14
-rw-r--r--spec/services/users/migrate_to_ghost_user_service_spec.rb6
-rw-r--r--spec/services/verify_pages_domain_service_spec.rb2
-rw-r--r--spec/services/wiki_pages/base_service_spec.rb2
-rw-r--r--spec/services/wiki_pages/create_service_spec.rb93
-rw-r--r--spec/services/wiki_pages/destroy_service_spec.rb49
-rw-r--r--spec/services/wiki_pages/event_create_service_spec.rb87
-rw-r--r--spec/services/wiki_pages/update_service_spec.rb97
-rw-r--r--spec/services/wikis/create_attachment_service_spec.rb67
-rw-r--r--spec/spec_helper.rb52
-rw-r--r--spec/support/capybara.rb13
-rw-r--r--spec/support/cycle_analytics_helpers/test_generation.rb4
-rw-r--r--spec/support/database_cleaner.rb2
-rw-r--r--spec/support/helpers/admin_mode_helpers.rb3
-rw-r--r--spec/support/helpers/concurrent_helpers.rb40
-rw-r--r--spec/support/helpers/design_management_test_helpers.rb45
-rw-r--r--spec/support/helpers/exclusive_lease_helpers.rb4
-rw-r--r--spec/support/helpers/fake_blob_helpers.rb6
-rw-r--r--spec/support/helpers/graphql_helpers.rb9
-rw-r--r--spec/support/helpers/jira_service_helper.rb5
-rw-r--r--spec/support/helpers/kubernetes_helpers.rb112
-rw-r--r--spec/support/helpers/login_helpers.rb2
-rw-r--r--spec/support/helpers/query_recorder.rb4
-rw-r--r--spec/support/helpers/reactive_caching_helpers.rb11
-rw-r--r--spec/support/helpers/smime_helper.rb14
-rw-r--r--spec/support/helpers/stub_feature_flags.rb34
-rw-r--r--spec/support/helpers/stub_gitlab_calls.rb8
-rw-r--r--spec/support/helpers/stub_object_storage.rb2
-rw-r--r--spec/support/helpers/test_env.rb20
-rw-r--r--spec/support/helpers/usage_data_helpers.rb95
-rw-r--r--spec/support/helpers/wiki_helpers.rb7
-rw-r--r--spec/support/helpers/workhorse_helpers.rb2
-rw-r--r--spec/support/helpers/x509_helpers.rb137
-rw-r--r--spec/support/import_export/common_util.rb19
-rw-r--r--spec/support/import_export/configuration_helper.rb4
-rw-r--r--spec/support/kubeclient.rb10
-rw-r--r--spec/support/matchers/disallow_request_matchers.rb2
-rw-r--r--spec/support/matchers/graphql_matchers.rb10
-rw-r--r--spec/support/rails/test_case_patch.rb53
-rw-r--r--spec/support/redis/redis_shared_examples.rb2
-rw-r--r--spec/support/renameable_upload.rb15
-rw-r--r--spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb31
-rw-r--r--spec/support/shared_contexts/design_management_shared_contexts.rb38
-rw-r--r--spec/support/shared_contexts/features/error_tracking_shared_context.rb4
-rw-r--r--spec/support/shared_contexts/issuable/merge_request_shared_context.rb49
-rw-r--r--spec/support/shared_contexts/issuable/project_shared_context.rb16
-rw-r--r--spec/support/shared_contexts/json_response_shared_context.rb2
-rw-r--r--spec/support/shared_contexts/lib/gitlab/import_export/project/rake_task_object_storage_shared_context.rb17
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb2
-rw-r--r--spec/support/shared_contexts/policies/group_policy_shared_context.rb10
-rw-r--r--spec/support/shared_contexts/policies/project_policy_shared_context.rb3
-rw-r--r--spec/support/shared_contexts/project_service_shared_context.rb5
-rw-r--r--spec/support/shared_contexts/services_shared_context.rb3
-rw-r--r--spec/support/shared_contexts/spam_constants.rb7
-rw-r--r--spec/support/shared_examples/controllers/instance_statistics_controllers_shared_examples.rb20
-rw-r--r--spec/support/shared_examples/controllers/known_sign_in_shared_examples.rb59
-rw-r--r--spec/support/shared_examples/controllers/variables_shared_examples.rb25
-rw-r--r--spec/support/shared_examples/features/error_tracking_shared_example.rb14
-rw-r--r--spec/support/shared_examples/features/variable_list_shared_examples.rb3
-rw-r--r--spec/support/shared_examples/graphql/design_fields_shared_examples.rb80
-rw-r--r--spec/support/shared_examples/graphql/jira_import/jira_import_resolver_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb92
-rw-r--r--spec/support/shared_examples/helm_commands_shared_examples.rb131
-rw-r--r--spec/support/shared_examples/lib/gitlab/cycle_analytics/base_stage_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/lib/gitlab/helm_generated_script_shared_examples.rb15
-rw-r--r--spec/support/shared_examples/lib/gitlab/import_export/project/rake_task_object_storage_shared_examples.rb22
-rw-r--r--spec/support/shared_examples/lib/gitlab/migration_helpers_shared_examples.rb3
-rw-r--r--spec/support/shared_examples/models/chat_service_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/models/cluster_application_status_shared_examples.rb61
-rw-r--r--spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/models/concerns/has_repository_shared_examples.rb20
-rw-r--r--spec/support/shared_examples/models/concerns/has_wiki_shared_examples.rb79
-rw-r--r--spec/support/shared_examples/models/concerns/limitable_shared_examples.rb36
-rw-r--r--spec/support/shared_examples/models/concerns/timebox_shared_examples.rb242
-rw-r--r--spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb24
-rw-r--r--spec/support/shared_examples/models/email_format_shared_examples.rb41
-rw-r--r--spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb1
-rw-r--r--spec/support/shared_examples/models/mentionable_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/models/wiki_shared_examples.rb423
-rw-r--r--spec/support/shared_examples/policies/project_policy_shared_examples.rb14
-rw-r--r--spec/support/shared_examples/policies/wiki_policies_shared_examples.rb228
-rw-r--r--spec/support/shared_examples/quick_actions/issuable/issuable_quick_actions_shared_examples.rb22
-rw-r--r--spec/support/shared_examples/requests/api/award_emoji_todo_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/graphql/mutations/snippets_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/requests/snippet_shared_examples.rb70
-rw-r--r--spec/support/shared_examples/requires_variables_shared_example.rb13
-rw-r--r--spec/support/shared_examples/resource_events.rb18
-rw-r--r--spec/support/shared_examples/serializers/diff_file_entity_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/services/boards/lists_list_service_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/services/measurable_service_shared_examples.rb32
-rw-r--r--spec/support/shared_examples/services/metrics/dashboard_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/services/projects/update_repository_storage_service_shared_examples.rb26
-rw-r--r--spec/support/shared_examples/services/resource_events/change_milestone_service_shared_examples.rb7
-rw-r--r--spec/support/shared_examples/services/snippets_shared_examples.rb42
-rw-r--r--spec/support/shared_examples/services/wiki_pages/create_service_shared_examples.rb94
-rw-r--r--spec/support/shared_examples/services/wiki_pages/destroy_service_shared_examples.rb50
-rw-r--r--spec/support/shared_examples/services/wiki_pages/update_service_shared_examples.rb98
-rw-r--r--spec/support/shared_examples/services/wikis/create_attachment_service_shared_examples.rb62
-rw-r--r--spec/support/shared_examples/tasks/gitlab/import_export/measurable_shared_examples.rb31
-rw-r--r--spec/support/shared_examples/workers/authorized_projects_worker_shared_example.rb42
-rw-r--r--spec/support/shared_examples/workers/gitlab/jira_import/jira_import_workers_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/workers/pages_domain_cron_worker_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/workers/reactive_cacheable_shared_examples.rb59
-rw-r--r--spec/support/sidekiq.rb13
-rw-r--r--spec/support/sidekiq_middleware.rb13
-rw-r--r--spec/support/unicorn.rb27
-rw-r--r--spec/support/webmock.rb18
-rw-r--r--spec/support_specs/helpers/active_record/query_recorder_spec.rb8
-rw-r--r--spec/support_specs/helpers/stub_feature_flags_spec.rb130
-rw-r--r--spec/tasks/gitlab/artifacts/migrate_rake_spec.rb12
-rw-r--r--spec/tasks/gitlab/gitaly_rake_spec.rb10
-rw-r--r--spec/tasks/gitlab/snippets_rake_spec.rb114
-rw-r--r--spec/tasks/gitlab/task_helpers_spec.rb15
-rw-r--r--spec/tasks/gitlab/uploads/migrate_rake_spec.rb12
-rw-r--r--spec/tasks/gitlab/workhorse_rake_spec.rb2
-rw-r--r--spec/uploaders/content_type_whitelist_spec.rb18
-rw-r--r--spec/uploaders/design_management/design_v432x230_uploader_spec.rb86
-rw-r--r--spec/uploaders/records_uploads_spec.rb4
-rw-r--r--spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb12
-rw-r--r--spec/validators/cron_freeze_period_timezone_validator_spec.rb24
-rw-r--r--spec/validators/cron_validator_spec.rb47
-rw-r--r--spec/views/admin/sessions/new.html.haml_spec.rb59
-rw-r--r--spec/views/admin/users/_user.html.haml_spec.rb12
-rw-r--r--spec/views/devise/sessions/new.html.haml_spec.rb4
-rw-r--r--spec/views/devise/shared/_signin_box.html.haml_spec.rb2
-rw-r--r--spec/views/help/index.html.haml_spec.rb12
-rw-r--r--spec/views/help/show.html.haml_spec.rb18
-rw-r--r--spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb9
-rw-r--r--spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb62
-rw-r--r--spec/views/profiles/show.html.haml_spec.rb44
-rw-r--r--spec/views/projects/issues/_related_branches.html.haml_spec.rb24
-rw-r--r--spec/views/projects/issues/show.html.haml_spec.rb27
-rw-r--r--spec/views/projects/merge_requests/show.html.haml_spec.rb60
-rw-r--r--spec/views/projects/services/_form.haml_spec.rb17
-rw-r--r--spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb2
-rw-r--r--spec/workers/authorized_project_update/project_create_worker_spec.rb50
-rw-r--r--spec/workers/authorized_project_update/user_refresh_with_low_urgency_worker_spec.rb11
-rw-r--r--spec/workers/authorized_projects_worker_spec.rb37
-rw-r--r--spec/workers/ci/daily_build_group_report_results_worker_spec.rb34
-rw-r--r--spec/workers/ci/daily_report_results_worker_spec.rb34
-rw-r--r--spec/workers/concerns/application_worker_spec.rb15
-rw-r--r--spec/workers/create_commit_signature_worker_spec.rb19
-rw-r--r--spec/workers/design_management/new_version_worker_spec.rb72
-rw-r--r--spec/workers/external_service_reactive_caching_worker_spec.rb7
-rw-r--r--spec/workers/gitlab/jira_import/import_issue_worker_spec.rb39
-rw-r--r--spec/workers/gitlab/jira_import/stage/finish_import_worker_spec.rb4
-rw-r--r--spec/workers/gitlab/jira_import/stage/import_attachments_worker_spec.rb4
-rw-r--r--spec/workers/gitlab/jira_import/stage/import_issues_worker_spec.rb7
-rw-r--r--spec/workers/gitlab/jira_import/stage/import_labels_worker_spec.rb11
-rw-r--r--spec/workers/gitlab/jira_import/stage/import_notes_worker_spec.rb4
-rw-r--r--spec/workers/group_import_worker_spec.rb59
-rw-r--r--spec/workers/incident_management/process_alert_worker_spec.rb67
-rw-r--r--spec/workers/merge_request_mergeability_check_worker_spec.rb11
-rw-r--r--spec/workers/namespaceless_project_destroy_worker_spec.rb14
-rw-r--r--spec/workers/new_release_worker_spec.rb2
-rw-r--r--spec/workers/post_receive_spec.rb44
-rw-r--r--spec/workers/process_commit_worker_spec.rb24
-rw-r--r--spec/workers/project_export_worker_spec.rb10
-rw-r--r--spec/workers/project_update_repository_storage_worker_spec.rb36
-rw-r--r--spec/workers/reactive_caching_worker_spec.rb44
-rw-r--r--spec/workers/stage_update_worker_spec.rb9
-rw-r--r--spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb65
-rw-r--r--spec/workers/update_highest_role_worker_spec.rb2
-rw-r--r--spec/workers/x509_issuer_crl_check_worker_spec.rb90
1977 files changed, 84225 insertions, 40231 deletions
diff --git a/spec/channels/application_cable/connection_spec.rb b/spec/channels/application_cable/connection_spec.rb
new file mode 100644
index 00000000000..f3d67133528
--- /dev/null
+++ b/spec/channels/application_cable/connection_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ApplicationCable::Connection, :clean_gitlab_redis_shared_state do
+ let(:session_id) { Rack::Session::SessionId.new('6919a6f1bb119dd7396fadc38fd18d0d') }
+
+ before do
+ 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
+
+ context 'when user is logged in' do
+ let(:user) { create(:user) }
+ let(:session_hash) { { 'warden.user.user.key' => [[user.id], user.encrypted_password[0, 29]] } }
+
+ it 'sets current_user' do
+ connect
+
+ expect(connection.current_user).to eq(user)
+ end
+
+ context 'with a stale password' do
+ let(:partial_password_hash) { build(:user, password: 'some_old_password').encrypted_password[0, 29] }
+ let(:session_hash) { { 'warden.user.user.key' => [[user.id], partial_password_hash] } }
+
+ it 'sets current_user to nil' do
+ connect
+
+ expect(connection.current_user).to be_nil
+ end
+ end
+ end
+
+ context 'when user is not logged in' do
+ let(:session_hash) { {} }
+
+ it 'sets current_user to nil' do
+ connect
+
+ expect(connection.current_user).to be_nil
+ end
+ end
+end
diff --git a/spec/channels/issues_channel_spec.rb b/spec/channels/issues_channel_spec.rb
new file mode 100644
index 00000000000..1c88cc73456
--- /dev/null
+++ b/spec/channels/issues_channel_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe IssuesChannel do
+ let_it_be(:issue) { create(:issue) }
+
+ it 'rejects when project path is invalid' do
+ subscribe(project_path: 'invalid_project_path', iid: issue.iid)
+
+ expect(subscription).to be_rejected
+ end
+
+ it 'rejects when iid is invalid' do
+ subscribe(project_path: issue.project.full_path, iid: non_existing_record_iid)
+
+ expect(subscription).to be_rejected
+ end
+
+ it 'rejects when the user does not have access' do
+ stub_connection current_user: nil
+
+ subscribe(project_path: issue.project.full_path, iid: issue.iid)
+
+ expect(subscription).to be_rejected
+ end
+
+ it 'subscribes to a stream when the user has access' do
+ stub_connection current_user: issue.author
+
+ subscribe(project_path: issue.project.full_path, iid: issue.iid)
+
+ expect(subscription).to be_confirmed
+ expect(subscription).to have_stream_for(issue)
+ end
+end
diff --git a/spec/config/application_spec.rb b/spec/config/application_spec.rb
index 994cea4c84f..e6b8da690a2 100644
--- a/spec/config/application_spec.rb
+++ b/spec/config/application_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
describe Gitlab::Application do # rubocop:disable RSpec/FilePath
using RSpec::Parameterized::TableSyntax
- FILTERED_PARAM = ActiveSupport::ParameterFilter::FILTERED
+ filtered_param = ActiveSupport::ParameterFilter::FILTERED
context 'when parameters are logged' do
describe 'rails does not leak confidential parameters' do
@@ -19,11 +19,11 @@ describe Gitlab::Application do # rubocop:disable RSpec/FilePath
where(:input_url, :output_query) do
'/' | {}
'/?safe=1' | { 'safe' => '1' }
- '/?private_token=secret' | { 'private_token' => FILTERED_PARAM }
- '/?mixed=1&private_token=secret' | { 'mixed' => '1', 'private_token' => FILTERED_PARAM }
- '/?note=secret&noteable=1&prefix_note=2' | { 'note' => FILTERED_PARAM, 'noteable' => '1', 'prefix_note' => '2' }
- '/?note[note]=secret&target_type=1' | { 'note' => FILTERED_PARAM, 'target_type' => '1' }
- '/?safe[note]=secret&target_type=1' | { 'safe' => { 'note' => FILTERED_PARAM }, 'target_type' => '1' }
+ '/?private_token=secret' | { 'private_token' => filtered_param }
+ '/?mixed=1&private_token=secret' | { 'mixed' => '1', 'private_token' => filtered_param }
+ '/?note=secret&noteable=1&prefix_note=2' | { 'note' => filtered_param, 'noteable' => '1', 'prefix_note' => '2' }
+ '/?note[note]=secret&target_type=1' | { 'note' => filtered_param, 'target_type' => '1' }
+ '/?safe[note]=secret&target_type=1' | { 'safe' => { 'note' => filtered_param }, 'target_type' => '1' }
end
with_them do
diff --git a/spec/config/mail_room_spec.rb b/spec/config/mail_room_spec.rb
index fcef4e7a9b0..bd8269fb2c5 100644
--- a/spec/config/mail_room_spec.rb
+++ b/spec/config/mail_room_spec.rb
@@ -53,7 +53,8 @@ describe 'mail_room.yml' do
email: 'gitlab-incoming@gmail.com',
password: '[REDACTED]',
name: 'inbox',
- idle_timeout: 60
+ idle_timeout: 60,
+ expunge_deleted: true
}
expected_options = {
redis_url: gitlab_redis_queues.url,
diff --git a/spec/config/smime_signature_settings_spec.rb b/spec/config/smime_signature_settings_spec.rb
index 4f076a92b16..7e7b42b129a 100644
--- a/spec/config/smime_signature_settings_spec.rb
+++ b/spec/config/smime_signature_settings_spec.rb
@@ -6,6 +6,7 @@ describe SmimeSignatureSettings do
describe '.parse' do
let(:default_smime_key) { Rails.root.join('.gitlab_smime_key') }
let(:default_smime_cert) { Rails.root.join('.gitlab_smime_cert') }
+ let(:default_smime_ca_certs) { nil }
it 'sets correct default values to disabled' do
parsed_settings = described_class.parse(nil)
@@ -13,6 +14,7 @@ describe SmimeSignatureSettings do
expect(parsed_settings['enabled']).to be(false)
expect(parsed_settings['key_file']).to eq(default_smime_key)
expect(parsed_settings['cert_file']).to eq(default_smime_cert)
+ expect(parsed_settings['ca_certs_file']).to eq(default_smime_ca_certs)
end
context 'when providing custom values' do
@@ -24,6 +26,7 @@ describe SmimeSignatureSettings do
expect(parsed_settings['enabled']).to be(false)
expect(parsed_settings['key_file']).to eq(default_smime_key)
expect(parsed_settings['cert_file']).to eq(default_smime_cert)
+ expect(parsed_settings['ca_certs_file']).to eq(default_smime_ca_certs)
end
it 'enables smime with default key and cert' do
@@ -36,15 +39,18 @@ describe SmimeSignatureSettings do
expect(parsed_settings['enabled']).to be(true)
expect(parsed_settings['key_file']).to eq(default_smime_key)
expect(parsed_settings['cert_file']).to eq(default_smime_cert)
+ expect(parsed_settings['ca_certs_file']).to eq(default_smime_ca_certs)
end
it 'enables smime with custom key and cert' do
custom_key = '/custom/key'
custom_cert = '/custom/cert'
+ custom_ca_certs = '/custom/ca_certs'
custom_settings = Settingslogic.new({
'enabled' => true,
'key_file' => custom_key,
- 'cert_file' => custom_cert
+ 'cert_file' => custom_cert,
+ 'ca_certs_file' => custom_ca_certs
})
parsed_settings = described_class.parse(custom_settings)
@@ -52,6 +58,7 @@ describe SmimeSignatureSettings do
expect(parsed_settings['enabled']).to be(true)
expect(parsed_settings['key_file']).to eq(custom_key)
expect(parsed_settings['cert_file']).to eq(custom_cert)
+ expect(parsed_settings['ca_certs_file']).to eq(custom_ca_certs)
end
end
end
diff --git a/spec/controllers/admin/ci/variables_controller_spec.rb b/spec/controllers/admin/ci/variables_controller_spec.rb
new file mode 100644
index 00000000000..57f2dd21f39
--- /dev/null
+++ b/spec/controllers/admin/ci/variables_controller_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Admin::Ci::VariablesController do
+ let_it_be(:variable) { create(:ci_instance_variable) }
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'GET #show' do
+ subject do
+ get :show, params: {}, format: :json
+ end
+
+ context 'when signed in as admin' do
+ let(:user) { create(:admin) }
+
+ include_examples 'GET #show lists all variables'
+ end
+
+ context 'when signed in as regular user' do
+ let(:user) { create(:user) }
+
+ it 'returns 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'PATCH #update' do
+ subject do
+ patch :update,
+ params: {
+ variables_attributes: variables_attributes
+ },
+ format: :json
+ end
+
+ context 'when signed in as admin' do
+ let(:user) { create(:admin) }
+
+ include_examples 'PATCH #update updates variables' do
+ let(:variables_scope) { Ci::InstanceVariable.all }
+ let(:file_variables_scope) { variables_scope.file }
+ end
+ end
+
+ context 'when signed in as regular user' do
+ let(:user) { create(:user) }
+
+ let(:variables_attributes) do
+ [{
+ id: variable.id,
+ key: variable.key,
+ secret_value: 'new value'
+ }]
+ end
+
+ it 'returns 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/admin/clusters_controller_spec.rb b/spec/controllers/admin/clusters_controller_spec.rb
index bd6d5614ccd..d4a12e0dc52 100644
--- a/spec/controllers/admin/clusters_controller_spec.rb
+++ b/spec/controllers/admin/clusters_controller_spec.rb
@@ -27,7 +27,7 @@ describe Admin::ClustersController do
create(:cluster, :disabled, :provided_by_gcp, :production_environment, :instance)
end
- it 'lists available clusters' do
+ it 'lists available clusters and displays html' do
get_index
expect(response).to have_gitlab_http_status(:ok)
@@ -35,20 +35,39 @@ describe Admin::ClustersController do
expect(assigns(:clusters)).to match_array([enabled_cluster, disabled_cluster])
end
+ it 'lists available clusters and renders json serializer' do
+ get_index(format: :json)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('cluster_list')
+ end
+
context 'when page is specified' do
let(:last_page) { Clusters::Cluster.instance_type.page.total_pages }
+ let(:total_count) { Clusters::Cluster.instance_type.page.total_count }
before do
- allow(Clusters::Cluster).to receive(:paginates_per).and_return(1)
- create_list(:cluster, 2, :provided_by_gcp, :production_environment, :instance)
+ create_list(:cluster, 30, :provided_by_gcp, :production_environment, :instance)
end
it 'redirects to the page' do
+ expect(last_page).to be > 1
+
get_index(page: last_page)
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:clusters).current_page).to eq(last_page)
end
+
+ it 'displays cluster list for associated page' do
+ expect(last_page).to be > 1
+
+ get_index(page: last_page, format: :json)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['X-Page'].to_i).to eq(last_page)
+ expect(response.headers['X-Total'].to_i).to eq(total_count)
+ end
end
end
diff --git a/spec/controllers/admin/requests_profiles_controller_spec.rb b/spec/controllers/admin/requests_profiles_controller_spec.rb
index 13123c8e486..629233b04e7 100644
--- a/spec/controllers/admin/requests_profiles_controller_spec.rb
+++ b/spec/controllers/admin/requests_profiles_controller_spec.rb
@@ -27,7 +27,7 @@ describe Admin::RequestsProfilesController do
end
context 'when loading HTML profile' do
- let(:basename) { "profile_#{Time.now.to_i}_execution.html" }
+ let(:basename) { "profile_#{Time.current.to_i}_execution.html" }
let(:sample_data) do
'<html> <body> <h1>Heading</h1> <p>paragraph.</p> </body> </html>'
@@ -42,7 +42,7 @@ describe Admin::RequestsProfilesController do
end
context 'when loading TXT profile' do
- let(:basename) { "profile_#{Time.now.to_i}_memory.txt" }
+ let(:basename) { "profile_#{Time.current.to_i}_memory.txt" }
let(:sample_data) do
<<~TXT
@@ -60,7 +60,7 @@ describe Admin::RequestsProfilesController do
end
context 'when loading PDF profile' do
- let(:basename) { "profile_#{Time.now.to_i}_anything.pdf" }
+ let(:basename) { "profile_#{Time.current.to_i}_anything.pdf" }
let(:sample_data) { 'mocked pdf content' }
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index 387fc0407b6..7a7201a6454 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -296,7 +296,7 @@ describe Admin::UsersController do
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.now)
+ .to change { user.reload.password_expires_at }.to be_within(2.seconds).of(Time.current)
end
end
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index 2a913069acc..ed2e61d6cf6 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -14,7 +14,7 @@ describe ApplicationController do
end
it 'redirects if the user is over their password expiry' do
- user.password_expires_at = Time.new(2002)
+ user.password_expires_at = Time.zone.local(2002)
expect(user.ldap_user?).to be_falsey
allow(controller).to receive(:current_user).and_return(user)
@@ -25,7 +25,7 @@ describe ApplicationController do
end
it 'does not redirect if the user is under their password expiry' do
- user.password_expires_at = Time.now + 20010101
+ user.password_expires_at = Time.current + 20010101
expect(user.ldap_user?).to be_falsey
allow(controller).to receive(:current_user).and_return(user)
@@ -35,7 +35,7 @@ describe ApplicationController do
end
it 'does not redirect if the user is over their password expiry but they are an ldap user' do
- user.password_expires_at = Time.new(2002)
+ user.password_expires_at = Time.zone.local(2002)
allow(user).to receive(:ldap_user?).and_return(true)
allow(controller).to receive(:current_user).and_return(user)
@@ -47,7 +47,7 @@ describe ApplicationController do
it 'does not redirect if the user is over their password expiry but password authentication is disabled for the web interface' do
stub_application_setting(password_authentication_enabled_for_web: false)
stub_application_setting(password_authentication_enabled_for_git: false)
- user.password_expires_at = Time.new(2002)
+ user.password_expires_at = Time.zone.local(2002)
allow(controller).to receive(:current_user).and_return(user)
expect(controller).not_to receive(:redirect_to)
@@ -530,6 +530,14 @@ describe ApplicationController do
expect(controller.last_payload).to include('correlation_id' => 'new-id')
end
+
+ it 'adds context metadata to the payload' do
+ sign_in user
+
+ get :index
+
+ expect(controller.last_payload[:metadata]).to include('meta.user' => user.username)
+ end
end
describe '#access_denied' do
@@ -891,7 +899,7 @@ describe ApplicationController do
end
it 'sets the group if it was available' do
- group = build(:group)
+ group = build_stubbed(:group)
controller.instance_variable_set(:@group, group)
get :index, format: :json
@@ -900,7 +908,7 @@ describe ApplicationController do
end
it 'sets the project if one was available' do
- project = build(:project)
+ project = build_stubbed(:project)
controller.instance_variable_set(:@project, project)
get :index, format: :json
@@ -913,5 +921,58 @@ describe ApplicationController do
expect(json_response['meta.caller_id']).to eq('AnonymousController#index')
end
+
+ it 'assigns the context to a variable for logging' do
+ get :index, format: :json
+
+ expect(assigns(:current_context)).to include('meta.user' => user.username)
+ end
+
+ it 'assigns the context when the action caused an error' do
+ allow(controller).to receive(:index) { raise 'Broken' }
+
+ expect { get :index, format: :json }.to raise_error('Broken')
+
+ expect(assigns(:current_context)).to include('meta.user' => user.username)
+ end
+ end
+
+ describe '#current_user' do
+ controller(described_class) do
+ def index; end
+ end
+
+ let_it_be(:impersonator) { create(:user) }
+ let_it_be(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when being impersonated' do
+ before do
+ allow(controller).to receive(:session).and_return({ impersonator_id: impersonator.id })
+ end
+
+ it 'returns a User with impersonator', :aggregate_failures do
+ get :index
+
+ expect(controller.current_user).to be_a(User)
+ expect(controller.current_user.impersonator).to eq(impersonator)
+ end
+ end
+
+ context 'when not being impersonated' do
+ before do
+ allow(controller).to receive(:session).and_return({})
+ end
+
+ it 'returns a User', :aggregate_failures do
+ get :index
+
+ expect(controller.current_user).to be_a(User)
+ expect(controller.current_user.impersonator).to be_nil
+ end
+ end
end
end
diff --git a/spec/controllers/concerns/issuable_actions_spec.rb b/spec/controllers/concerns/issuable_actions_spec.rb
index 7b0b4497f3f..2ab46992b99 100644
--- a/spec/controllers/concerns/issuable_actions_spec.rb
+++ b/spec/controllers/concerns/issuable_actions_spec.rb
@@ -14,7 +14,7 @@ describe IssuableActions do
klass = Class.new do
attr_reader :current_user, :project, :issuable
- def self.before_action(action, params = nil)
+ def self.before_action(action = nil, params = nil)
end
include IssuableActions
diff --git a/spec/controllers/concerns/metrics_dashboard_spec.rb b/spec/controllers/concerns/metrics_dashboard_spec.rb
index 4e42171e3d3..e2fa03670d9 100644
--- a/spec/controllers/concerns/metrics_dashboard_spec.rb
+++ b/spec/controllers/concerns/metrics_dashboard_spec.rb
@@ -45,7 +45,7 @@ describe MetricsDashboard do
it 'returns the specified dashboard' do
expect(json_response['dashboard']['dashboard']).to eq('Environment metrics')
expect(json_response).not_to have_key('all_dashboards')
- expect(json_response).not_to have_key('metrics_data')
+ expect(json_response).to have_key('metrics_data')
end
context 'when the params are in an alternate format' do
@@ -54,7 +54,7 @@ describe MetricsDashboard do
it 'returns the specified dashboard' do
expect(json_response['dashboard']['dashboard']).to eq('Environment metrics')
expect(json_response).not_to have_key('all_dashboards')
- expect(json_response).not_to have_key('metrics_data')
+ expect(json_response).to have_key('metrics_data')
end
end
@@ -114,6 +114,35 @@ describe MetricsDashboard do
end
end
end
+
+ context 'starred dashboards' do
+ let_it_be(:dashboard_yml) { fixture_file('lib/gitlab/metrics/dashboard/sample_dashboard.yml') }
+ let_it_be(:dashboards) do
+ {
+ '.gitlab/dashboards/test.yml' => dashboard_yml,
+ '.gitlab/dashboards/anomaly.yml' => dashboard_yml,
+ '.gitlab/dashboards/errors.yml' => dashboard_yml
+ }
+ end
+ let_it_be(:project) { create(:project, :custom_repo, files: dashboards) }
+
+ before do
+ create(:metrics_users_starred_dashboard, user: user, project: project, dashboard_path: '.gitlab/dashboards/errors.yml')
+ create(:metrics_users_starred_dashboard, user: user, project: project, dashboard_path: '.gitlab/dashboards/test.yml')
+ end
+
+ it 'adds starred dashboard information and sorts the list' do
+ all_dashboards = json_response['all_dashboards'].map { |dashboard| dashboard.slice('display_name', 'starred', 'user_starred_path') }
+ expected_response = [
+ { "display_name" => "Default", "starred" => false, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: 'config/prometheus/common_metrics.yml' }) },
+ { "display_name" => "anomaly.yml", "starred" => false, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: '.gitlab/dashboards/anomaly.yml' }) },
+ { "display_name" => "errors.yml", "starred" => true, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: '.gitlab/dashboards/errors.yml' }) },
+ { "display_name" => "test.yml", "starred" => true, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: '.gitlab/dashboards/test.yml' }) }
+ ]
+
+ expect(all_dashboards).to eql expected_response
+ end
+ end
end
end
end
diff --git a/spec/controllers/dashboard/projects_controller_spec.rb b/spec/controllers/dashboard/projects_controller_spec.rb
index a13b56deb23..eeac696c3f2 100644
--- a/spec/controllers/dashboard/projects_controller_spec.rb
+++ b/spec/controllers/dashboard/projects_controller_spec.rb
@@ -86,11 +86,58 @@ describe Dashboard::ProjectsController do
end
describe 'GET /starred.json' do
+ subject { get :starred, format: :json }
+
+ let(:projects) { create_list(:project, 2, creator: user) }
+
before do
- get :starred, format: :json
+ allow(Kaminari.config).to receive(:default_per_page).and_return(1)
+
+ projects.each do |project|
+ project.add_developer(user)
+ create(:users_star_project, project_id: project.id, user_id: user.id)
+ end
end
- it { is_expected.to respond_with(:success) }
+ it 'returns success' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'paginates the records' do
+ subject
+
+ expect(assigns(:projects).count).to eq(1)
+ end
+ end
+ end
+
+ context 'atom requests' do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ describe '#index' do
+ context 'project pagination' do
+ let(:projects) { create_list(:project, 2, creator: user) }
+
+ before do
+ allow(Kaminari.config).to receive(:default_per_page).and_return(1)
+
+ projects.each do |project|
+ project.add_developer(user)
+ end
+ end
+
+ it 'does not paginate projects, even if normally restricted by pagination' do
+ get :index, format: :atom
+
+ expect(assigns(:events).count).to eq(2)
+ end
+ end
end
end
end
diff --git a/spec/controllers/google_api/authorizations_controller_spec.rb b/spec/controllers/google_api/authorizations_controller_spec.rb
index 58bda2bd4e8..9d0e0d92978 100644
--- a/spec/controllers/google_api/authorizations_controller_spec.rb
+++ b/spec/controllers/google_api/authorizations_controller_spec.rb
@@ -12,10 +12,6 @@ describe GoogleApi::AuthorizationsController do
before do
sign_in(user)
-
- allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance|
- allow(instance).to receive(:get_token).and_return([token, expires_at])
- end
end
shared_examples_for 'access denied' do
@@ -38,6 +34,12 @@ describe GoogleApi::AuthorizationsController do
context 'session key matches state param' do
let(:state) { session_key }
+ before do
+ allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance|
+ allow(instance).to receive(:get_token).and_return([token, expires_at])
+ end
+ end
+
it 'sets token and expires_at in session' do
subject
@@ -63,6 +65,22 @@ describe GoogleApi::AuthorizationsController do
it_behaves_like 'access denied'
end
+
+ context 'when a Faraday exception occurs' do
+ let(:state) { session_key }
+
+ [::Faraday::TimeoutError, ::Faraday::ConnectionFailed].each do |error|
+ it "sets a flash alert on #{error}" do
+ allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance|
+ allow(instance).to receive(:get_token).and_raise(error.new(nil))
+ end
+
+ subject
+
+ expect(flash[:alert]).to eq('Timeout connecting to the Google API. Please try again.')
+ end
+ end
+ end
end
context 'state param is present, but session key is blank' do
diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb
index 06a949471a7..68150504fe3 100644
--- a/spec/controllers/graphql_controller_spec.rb
+++ b/spec/controllers/graphql_controller_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
describe GraphqlController do
+ include GraphqlHelpers
+
before do
stub_feature_flags(graphql: true)
end
@@ -30,7 +32,7 @@ describe GraphqlController do
describe 'POST #execute' do
context 'when user is logged in' do
- let(:user) { create(:user) }
+ let(:user) { create(:user, last_activity_on: Date.yesterday) }
before do
sign_in(user)
@@ -54,6 +56,19 @@ describe GraphqlController do
expect(response).to have_gitlab_http_status(:forbidden)
expect(response).to render_template('errors/access_denied')
end
+
+ it 'updates the users last_activity_on field' do
+ expect { post :execute }.to change { user.reload.last_activity_on }
+ 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]) }
+
+ it 'updates the users last_activity_on field' do
+ expect { post :execute, params: { access_token: token.token } }.to change { user.reload.last_activity_on }
+ end
end
context 'when user is not logged in' do
@@ -64,4 +79,52 @@ describe GraphqlController do
end
end
end
+
+ describe 'Admin Mode' do
+ let(:admin) { create(:admin) }
+ let(:project) { create(:project) }
+ let(:graphql_query) { graphql_query_for('project', { 'fullPath' => project.full_path }, %w(id name)) }
+
+ before do
+ sign_in(admin)
+ end
+
+ context 'when admin mode enabled' do
+ before do
+ Gitlab::Session.with_session(controller.session) do
+ controller.current_user_mode.request_admin_mode!
+ controller.current_user_mode.enable_admin_mode!(password: admin.password)
+ end
+ end
+
+ it 'can query project data' do
+ post :execute, params: { query: graphql_query }
+
+ expect(controller.current_user_mode.admin_mode?).to be(true)
+ expect(json_response['data']['project']['name']).to eq(project.name)
+ end
+ end
+
+ context 'when admin mode disabled' do
+ it 'cannot query project data' do
+ post :execute, params: { query: graphql_query }
+
+ expect(controller.current_user_mode.admin_mode?).to be(false)
+ expect(json_response['data']['project']).to be_nil
+ end
+
+ context 'when admin is member of the project' do
+ before do
+ project.add_developer(admin)
+ end
+
+ it 'can query project data' do
+ post :execute, params: { query: graphql_query }
+
+ expect(controller.current_user_mode.admin_mode?).to be(false)
+ expect(json_response['data']['project']['name']).to eq(project.name)
+ end
+ end
+ end
+ end
end
diff --git a/spec/controllers/groups/clusters_controller_spec.rb b/spec/controllers/groups/clusters_controller_spec.rb
index 28a174560dd..1f2f6bd811b 100644
--- a/spec/controllers/groups/clusters_controller_spec.rb
+++ b/spec/controllers/groups/clusters_controller_spec.rb
@@ -32,7 +32,7 @@ describe Groups::ClustersController do
create(:cluster, :disabled, :provided_by_gcp, :production_environment, cluster_type: :group_type, groups: [group])
end
- it 'lists available clusters' do
+ it 'lists available clusters and renders html' do
go
expect(response).to have_gitlab_http_status(:ok)
@@ -40,20 +40,39 @@ describe Groups::ClustersController do
expect(assigns(:clusters)).to match_array([enabled_cluster, disabled_cluster])
end
+ it 'lists available clusters with json serializer' do
+ go(format: :json)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('cluster_list')
+ end
+
context 'when page is specified' do
let(:last_page) { group.clusters.page.total_pages }
+ let(:total_count) { group.clusters.page.total_count }
before do
- allow(Clusters::Cluster).to receive(:paginates_per).and_return(1)
- create_list(:cluster, 2, :provided_by_gcp, :production_environment, cluster_type: :group_type, groups: [group])
+ create_list(:cluster, 30, :provided_by_gcp, :production_environment, cluster_type: :group_type, groups: [group])
end
it 'redirects to the page' do
+ expect(last_page).to be > 1
+
go(page: last_page)
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:clusters).current_page).to eq(last_page)
end
+
+ it 'displays cluster list for associated page' do
+ expect(last_page).to be > 1
+
+ go(page: last_page, format: :json)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['X-Page'].to_i).to eq(last_page)
+ expect(response.headers['X-Total'].to_i).to eq(total_count)
+ end
end
end
diff --git a/spec/controllers/groups/group_links_controller_spec.rb b/spec/controllers/groups/group_links_controller_spec.rb
index 21169188386..ca430414d17 100644
--- a/spec/controllers/groups/group_links_controller_spec.rb
+++ b/spec/controllers/groups/group_links_controller_spec.rb
@@ -99,18 +99,6 @@ describe Groups::GroupLinksController do
expect(flash[:alert]).to eq('error')
end
end
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(share_group_with_group: false)
- end
-
- it 'renders 404' do
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
end
context 'when user does not have access to the group' do
@@ -184,18 +172,6 @@ describe Groups::GroupLinksController do
expect(response).to have_gitlab_http_status(:not_found)
end
end
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(share_group_with_group: false)
- end
-
- it 'renders 404' do
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
end
describe '#destroy' do
@@ -231,17 +207,5 @@ describe Groups::GroupLinksController do
expect(response).to have_gitlab_http_status(:not_found)
end
end
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(share_group_with_group: false)
- end
-
- it 'renders 404' do
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
end
end
diff --git a/spec/controllers/groups/registry/repositories_controller_spec.rb b/spec/controllers/groups/registry/repositories_controller_spec.rb
index a84664c6c04..7b78aeadbd8 100644
--- a/spec/controllers/groups/registry/repositories_controller_spec.rb
+++ b/spec/controllers/groups/registry/repositories_controller_spec.rb
@@ -6,12 +6,13 @@ describe Groups::Registry::RepositoriesController do
let_it_be(:user) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:group, reload: true) { create(:group) }
+ let(:additional_parameters) { {} }
subject do
- get :index, params: {
+ get :index, params: additional_parameters.merge({
group_id: group,
format: format
- }
+ })
end
before do
@@ -36,6 +37,25 @@ describe Groups::Registry::RepositoriesController do
end
end
+ shared_examples 'with name parameter' do
+ let_it_be(:project) { create(:project, group: test_group) }
+ let_it_be(:repo) { create(:container_repository, project: project, name: 'my_searched_image') }
+ let_it_be(:another_repo) { create(:container_repository, project: project, name: 'bar') }
+
+ let(:additional_parameters) { { name: 'my_searched_image' } }
+
+ it 'returns the searched repo' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.length).to eq 1
+ expect(json_response.first).to include(
+ 'id' => repo.id,
+ 'name' => repo.name
+ )
+ end
+ end
+
shared_examples 'renders correctly' do
context 'when user has access to registry' do
let_it_be(:test_group) { group }
@@ -64,6 +84,8 @@ describe Groups::Registry::RepositoriesController do
it_behaves_like 'renders a list of repositories'
+ it_behaves_like 'with name parameter'
+
it_behaves_like 'a gitlab tracking event', described_class.name, 'list_repositories'
context 'with project in subgroup' do
@@ -71,6 +93,8 @@ describe Groups::Registry::RepositoriesController do
it_behaves_like 'renders a list of repositories'
+ it_behaves_like 'with name parameter'
+
context 'with project in subgroup and group' do
let_it_be(:repo_in_test_group) { create_project_with_repo(test_group) }
let_it_be(:repo_in_group) { create_project_with_repo(group) }
@@ -81,6 +105,8 @@ describe Groups::Registry::RepositoriesController do
expect(json_response).to be_kind_of(Array)
expect(json_response.length).to eq 2
end
+
+ it_behaves_like 'with name parameter'
end
end
end
diff --git a/spec/controllers/groups/settings/integrations_controller_spec.rb b/spec/controllers/groups/settings/integrations_controller_spec.rb
index 76cd74de183..29c93c621bd 100644
--- a/spec/controllers/groups/settings/integrations_controller_spec.rb
+++ b/spec/controllers/groups/settings/integrations_controller_spec.rb
@@ -27,7 +27,7 @@ describe Groups::Settings::IntegrationsController do
context 'when group_level_integrations not enabled' do
it 'returns not_found' do
- stub_feature_flags(group_level_integrations: { enabled: false, thing: group })
+ stub_feature_flags(group_level_integrations: false)
get :index, params: { group_id: group }
@@ -60,7 +60,7 @@ describe Groups::Settings::IntegrationsController do
context 'when group_level_integrations not enabled' do
it 'returns not_found' do
- stub_feature_flags(group_level_integrations: { enabled: false, thing: group })
+ stub_feature_flags(group_level_integrations: false)
get :edit, params: { group_id: group, id: Service.available_services_names.sample }
diff --git a/spec/controllers/groups/settings/repository_controller_spec.rb b/spec/controllers/groups/settings/repository_controller_spec.rb
index 20070fb17a0..9523d404538 100644
--- a/spec/controllers/groups/settings/repository_controller_spec.rb
+++ b/spec/controllers/groups/settings/repository_controller_spec.rb
@@ -15,7 +15,7 @@ describe Groups::Settings::RepositoryController do
describe 'POST create_deploy_token' do
context 'when ajax_new_deploy_token feature flag is disabled for the project' do
before do
- stub_feature_flags(ajax_new_deploy_token: { enabled: false, thing: group })
+ stub_feature_flags(ajax_new_deploy_token: false)
entity.add_owner(user)
end
@@ -56,7 +56,7 @@ describe Groups::Settings::RepositoryController do
'id' => be_a(Integer),
'name' => deploy_token_params[:name],
'username' => deploy_token_params[:username],
- 'expires_at' => Time.parse(deploy_token_params[:expires_at]),
+ 'expires_at' => Time.zone.parse(deploy_token_params[:expires_at]),
'token' => be_a(String),
'scopes' => deploy_token_params.inject([]) do |scopes, kv|
key, value = kv
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index 93478bbff1d..354c9e047c8 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -270,6 +270,37 @@ describe GroupsController do
it { expect(subject).to render_template(:new) }
end
+
+ context 'when creating a group with `default_branch_protection` attribute' do
+ before do
+ sign_in(user)
+ end
+
+ subject do
+ post :create, params: { group: { name: 'new_group', path: 'new_group', default_branch_protection: Gitlab::Access::PROTECTION_NONE } }
+ end
+
+ context 'for users who have the ability to create a group with `default_branch_protection`' do
+ it 'creates group with the specified branch protection level' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(Group.last.default_branch_protection).to eq(Gitlab::Access::PROTECTION_NONE)
+ end
+ end
+
+ context 'for users who do not have the ability to create a group with `default_branch_protection`' do
+ it 'does not create the group with the specified branch protection level' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user, :create_group_with_default_branch_protection) { false }
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(Group.last.default_branch_protection).not_to eq(Gitlab::Access::PROTECTION_NONE)
+ end
+ end
+ end
end
describe 'GET #index' do
@@ -423,11 +454,31 @@ describe GroupsController do
expect(group.reload.project_creation_level).to eq(::Gitlab::Access::MAINTAINER_PROJECT_ACCESS)
end
- it 'updates the default_branch_protection successfully' do
- post :update, params: { id: group.to_param, group: { default_branch_protection: ::Gitlab::Access::PROTECTION_DEV_CAN_MERGE } }
+ context 'updating default_branch_protection' do
+ subject do
+ put :update, params: { id: group.to_param, group: { default_branch_protection: ::Gitlab::Access::PROTECTION_DEV_CAN_MERGE } }
+ end
+
+ context 'for users who have the ability to update default_branch_protection' do
+ it 'updates the attribute' do
+ subject
- expect(response).to have_gitlab_http_status(:found)
- expect(group.reload.default_branch_protection).to eq(::Gitlab::Access::PROTECTION_DEV_CAN_MERGE)
+ expect(response).to have_gitlab_http_status(:found)
+ expect(group.reload.default_branch_protection).to eq(::Gitlab::Access::PROTECTION_DEV_CAN_MERGE)
+ end
+ end
+
+ context 'for users who do not have the ability to update default_branch_protection' do
+ it 'does not update the attribute' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user, :update_default_branch_protection, group) { false }
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(group.reload.default_branch_protection).not_to eq(::Gitlab::Access::PROTECTION_DEV_CAN_MERGE)
+ end
+ end
end
context 'when a project inside the group has container repositories' do
diff --git a/spec/controllers/help_controller_spec.rb b/spec/controllers/help_controller_spec.rb
index f03fee8d3ae..fafbe6bffe1 100644
--- a/spec/controllers/help_controller_spec.rb
+++ b/spec/controllers/help_controller_spec.rb
@@ -99,6 +99,7 @@ describe HelpController do
context 'for Markdown formats' do
context 'when requested file exists' do
before do
+ expect(File).to receive(:read).and_return(fixture_file('blockquote_fence_after.md'))
get :show, params: { path: 'ssh/README' }, format: :md
end
@@ -108,7 +109,7 @@ describe HelpController do
it 'renders HTML' do
expect(response).to render_template('show.html.haml')
- expect(response.content_type).to eq 'text/html'
+ expect(response.media_type).to eq 'text/html'
end
end
@@ -129,7 +130,7 @@ describe HelpController do
},
format: :png
expect(response).to be_successful
- expect(response.content_type).to eq 'image/png'
+ expect(response.media_type).to eq 'image/png'
expect(response.headers['Content-Disposition']).to match(/^inline;/)
end
end
@@ -168,6 +169,6 @@ describe HelpController do
end
def stub_readme(content)
- allow(File).to receive(:read).and_return(content)
+ expect(File).to receive(:read).and_return(content)
end
end
diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb
index ab4f6d5054c..d44edb63635 100644
--- a/spec/controllers/import/bitbucket_controller_spec.rb
+++ b/spec/controllers/import/bitbucket_controller_spec.rb
@@ -27,7 +27,7 @@ describe Import::BitbucketController do
end
it "updates access token" do
- expires_at = Time.now + 1.day
+ expires_at = Time.current + 1.day
expires_in = 1.day
access_token = double(token: token,
secret: secret,
diff --git a/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb b/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb
index ceab9754617..0242a91ac60 100644
--- a/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb
+++ b/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb
@@ -65,4 +65,55 @@ describe Ldap::OmniauthCallbacksController do
expect(request.env['warden']).to be_authenticated
end
end
+
+ describe 'enable admin mode' do
+ include_context 'custom session'
+
+ before do
+ sign_in user
+ end
+
+ context 'with a regular user' do
+ it 'cannot be enabled' do
+ reauthenticate_and_check_admin_mode(expected_admin_mode: false)
+
+ expect(response).to redirect_to(root_path)
+ end
+ end
+
+ context 'with an admin user' do
+ let(:user) { create(:omniauth_user, :admin, extern_uid: uid, provider: provider) }
+
+ context 'when requested first' do
+ before do
+ subject.current_user_mode.request_admin_mode!
+ end
+
+ it 'can be enabled' do
+ reauthenticate_and_check_admin_mode(expected_admin_mode: true)
+
+ expect(response).to redirect_to(admin_root_path)
+ end
+ end
+
+ context 'when not requested first' do
+ it 'cannot be enabled' do
+ reauthenticate_and_check_admin_mode(expected_admin_mode: false)
+
+ expect(response).to redirect_to(root_path)
+ end
+ end
+ end
+ end
+
+ def reauthenticate_and_check_admin_mode(expected_admin_mode:)
+ # Initially admin mode disabled
+ expect(subject.current_user_mode.admin_mode?).to be(false)
+
+ # Trigger OmniAuth admin mode flow and expect admin mode status
+ post provider
+
+ expect(request.env['warden']).to be_authenticated
+ expect(subject.current_user_mode.admin_mode?).to be(expected_admin_mode)
+ end
end
diff --git a/spec/controllers/oauth/token_info_controller_spec.rb b/spec/controllers/oauth/token_info_controller_spec.rb
index 4b3539879df..4658c2702ca 100644
--- a/spec/controllers/oauth/token_info_controller_spec.rb
+++ b/spec/controllers/oauth/token_info_controller_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Oauth::TokenInfoController do
get :show
expect(response).to have_gitlab_http_status(:unauthorized)
- expect(JSON.parse(response.body)).to include('error' => 'invalid_request')
+ expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_request')
end
end
@@ -23,7 +23,7 @@ RSpec.describe Oauth::TokenInfoController do
get :show, params: { access_token: access_token.token }
expect(response).to have_gitlab_http_status(:ok)
- expect(JSON.parse(response.body)).to eq(
+ expect(Gitlab::Json.parse(response.body)).to eq(
'scope' => %w[api],
'scopes' => %w[api],
'created_at' => access_token.created_at.to_i,
@@ -40,7 +40,7 @@ RSpec.describe Oauth::TokenInfoController do
get :show, params: { access_token: 'unknown_token' }
expect(response).to have_gitlab_http_status(:unauthorized)
- expect(JSON.parse(response.body)).to include('error' => 'invalid_request')
+ expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_request')
end
end
@@ -53,7 +53,7 @@ RSpec.describe Oauth::TokenInfoController do
get :show, params: { access_token: access_token.token }
expect(response).to have_gitlab_http_status(:unauthorized)
- expect(JSON.parse(response.body)).to include('error' => 'invalid_request')
+ expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_request')
end
end
@@ -64,7 +64,7 @@ RSpec.describe Oauth::TokenInfoController do
get :show, params: { access_token: access_token.token }
expect(response).to have_gitlab_http_status(:unauthorized)
- expect(JSON.parse(response.body)).to include('error' => 'invalid_request')
+ expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_request')
end
end
end
diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb
index 9537ff62f8b..0d8a6827afe 100644
--- a/spec/controllers/omniauth_callbacks_controller_spec.rb
+++ b/spec/controllers/omniauth_callbacks_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-describe OmniauthCallbacksController, type: :controller, do_not_mock_admin_mode: true do
+describe OmniauthCallbacksController, type: :controller do
include LoginHelpers
describe 'omniauth' do
@@ -144,6 +144,10 @@ describe OmniauthCallbacksController, type: :controller, do_not_mock_admin_mode:
let(:extern_uid) { 'my-uid' }
let(:provider) { :github }
+ it_behaves_like 'known sign in' do
+ let(:post_action) { post provider }
+ end
+
it 'allows sign in' do
post provider
@@ -287,6 +291,11 @@ describe OmniauthCallbacksController, type: :controller, do_not_mock_admin_mode:
request.env['omniauth.auth'] = Rails.application.env_config['omniauth.auth']
end
+ it_behaves_like 'known sign in' do
+ let(:user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'saml') }
+ let(:post_action) { post :saml, params: { SAMLResponse: mock_saml_response } }
+ end
+
context 'sign up' do
before do
user.destroy
diff --git a/spec/controllers/profiles/emails_controller_spec.rb b/spec/controllers/profiles/emails_controller_spec.rb
index 7c6b1863202..ffec43fea2c 100644
--- a/spec/controllers/profiles/emails_controller_spec.rb
+++ b/spec/controllers/profiles/emails_controller_spec.rb
@@ -9,13 +9,27 @@ describe Profiles::EmailsController do
sign_in(user)
end
+ around do |example|
+ perform_enqueued_jobs do
+ example.run
+ end
+ end
+
describe '#create' do
- let(:email_params) { { email: "add_email@example.com" } }
+ context 'when email address is valid' do
+ let(:email_params) { { email: "add_email@example.com" } }
- it 'sends an email confirmation' do
- expect { post(:create, params: { email: email_params }) }.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"
+ it 'sends an email confirmation' do
+ expect { post(:create, params: { email: email_params }) }.to change { ActionMailer::Base.deliveries.size }
+ end
+ end
+
+ context 'when email address is invalid' do
+ let(:email_params) { { email: "test.@example.com" } }
+
+ it 'does not send an email confirmation' do
+ expect { post(:create, params: { email: email_params }) }.not_to change { ActionMailer::Base.deliveries.size }
+ end
end
end
diff --git a/spec/controllers/projects/alert_management_controller_spec.rb b/spec/controllers/projects/alert_management_controller_spec.rb
new file mode 100644
index 00000000000..b84376db33d
--- /dev/null
+++ b/spec/controllers/projects/alert_management_controller_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Projects::AlertManagementController do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:role) { :developer }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:id) { 1 }
+
+ before do
+ project.add_role(user, role)
+ sign_in(user)
+ end
+
+ describe 'GET #index' do
+ it 'shows the page' do
+ get :index, params: { namespace_id: project.namespace, project_id: project }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ context 'when user is unauthorized' do
+ let(:role) { :reporter }
+
+ it 'shows 404' do
+ get :index, params: { namespace_id: project.namespace, project_id: project }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'GET #details' do
+ it 'shows the page' do
+ get :details, params: { namespace_id: project.namespace, project_id: project, id: id }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ context 'when user is unauthorized' do
+ let(:role) { :reporter }
+
+ it 'shows 404' do
+ get :index, params: { namespace_id: project.namespace, project_id: project }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'set_alert_id' do
+ it 'sets alert id from the route' do
+ get :details, params: { namespace_id: project.namespace, project_id: project, id: id }
+
+ expect(assigns(:alert_id)).to eq(id.to_s)
+ end
+ end
+end
diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb
index c59983d5138..be616b566dd 100644
--- a/spec/controllers/projects/artifacts_controller_spec.rb
+++ b/spec/controllers/projects/artifacts_controller_spec.rb
@@ -308,10 +308,13 @@ describe Projects::ArtifactsController do
end
describe 'GET raw' do
- subject { get(:raw, params: { namespace_id: project.namespace, project_id: project, job_id: job, path: path }) }
+ let(:query_params) { { namespace_id: project.namespace, project_id: project, job_id: job, path: path } }
+
+ subject { get(:raw, params: query_params) }
context 'when the file exists' do
let(:path) { 'ci_artifacts.txt' }
+ let(:archive_matcher) { /build_artifacts.zip(\?[^?]+)?$/ }
shared_examples 'a valid file' do
it 'serves the file using workhorse' do
@@ -323,8 +326,8 @@ describe Projects::ArtifactsController do
expect(params.keys).to eq(%w(Archive Entry))
expect(params['Archive']).to start_with(archive_path)
# On object storage, the URL can end with a query string
- expect(params['Archive']).to match(/build_artifacts.zip(\?[^?]+)?$/)
- expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt'))
+ expect(params['Archive']).to match(archive_matcher)
+ expect(params['Entry']).to eq(Base64.encode64(path))
end
def send_data
@@ -334,7 +337,7 @@ describe Projects::ArtifactsController do
def params
@params ||= begin
base64_params = send_data.sub(/\Aartifacts\-entry:/, '')
- JSON.parse(Base64.urlsafe_decode64(base64_params))
+ Gitlab::Json.parse(Base64.urlsafe_decode64(base64_params))
end
end
end
@@ -359,6 +362,37 @@ describe Projects::ArtifactsController do
let(:archive_path) { 'https://' }
end
end
+
+ context 'fetching an artifact of different type' do
+ before do
+ job.job_artifacts.each(&:destroy)
+ end
+
+ context 'when the artifact is zip' do
+ let!(:artifact) { create(:ci_job_artifact, :lsif, job: job, file_path: Rails.root.join("spec/fixtures/#{file_name}")) }
+ let(:path) { 'lsif/main.go.json' }
+ let(:file_name) { 'lsif.json.zip' }
+ let(:archive_matcher) { file_name }
+ let(:query_params) { super().merge(file_type: :lsif, path: path) }
+
+ it_behaves_like 'a valid file' do
+ let(:store) { ObjectStorage::Store::LOCAL }
+ let(:archive_path) { JobArtifactUploader.root }
+ end
+ end
+
+ context 'when the artifact is not zip' do
+ let(:query_params) { super().merge(file_type: :junit, path: '') }
+
+ it 'responds with not found' do
+ create(:ci_job_artifact, :junit, job: job)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
end
end
diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb
index 85d3044993e..174d8904481 100644
--- a/spec/controllers/projects/branches_controller_spec.rb
+++ b/spec/controllers/projects/branches_controller_spec.rb
@@ -124,57 +124,39 @@ describe Projects::BranchesController do
)
end
- context 'create_confidential_merge_request feature is enabled' do
+ context 'user cannot update issue' do
+ let(:issue) { create(:issue, project: confidential_issue_project) }
+
+ it 'does not post a system note' do
+ expect(SystemNoteService).not_to receive(:new_issue_branch)
+
+ create_branch_with_confidential_issue_project
+ end
+ end
+
+ context 'user can update issue' do
before do
- stub_feature_flags(create_confidential_merge_request: true)
+ confidential_issue_project.add_reporter(user)
end
- context 'user cannot update issue' do
+ context 'issue is under the specified project' do
let(:issue) { create(:issue, project: confidential_issue_project) }
- it 'does not post a system note' do
- expect(SystemNoteService).not_to receive(:new_issue_branch)
+ it 'posts a system note' do
+ expect(SystemNoteService).to receive(:new_issue_branch).with(issue, confidential_issue_project, user, "1-feature-branch", branch_project: project)
create_branch_with_confidential_issue_project
end
end
- context 'user can update issue' do
- before do
- confidential_issue_project.add_reporter(user)
- end
-
- context 'issue is under the specified project' do
- let(:issue) { create(:issue, project: confidential_issue_project) }
-
- it 'posts a system note' do
- expect(SystemNoteService).to receive(:new_issue_branch).with(issue, confidential_issue_project, user, "1-feature-branch", branch_project: project)
-
- create_branch_with_confidential_issue_project
- end
- end
-
- context 'issue is not under the specified project' do
- it 'does not post a system note' do
- expect(SystemNoteService).not_to receive(:new_issue_branch)
+ context 'issue is not under the specified project' do
+ it 'does not post a system note' do
+ expect(SystemNoteService).not_to receive(:new_issue_branch)
- create_branch_with_confidential_issue_project
- end
+ create_branch_with_confidential_issue_project
end
end
end
-
- context 'create_confidential_merge_request feature is disabled' do
- before do
- stub_feature_flags(create_confidential_merge_request: false)
- end
-
- it 'posts a system note on project' do
- expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, "1-feature-branch", branch_project: project)
-
- create_branch_with_confidential_issue_project
- end
- end
end
context 'repository-less project' do
diff --git a/spec/controllers/projects/ci/daily_build_group_report_results_controller_spec.rb b/spec/controllers/projects/ci/daily_build_group_report_results_controller_spec.rb
new file mode 100644
index 00000000000..ac31045678f
--- /dev/null
+++ b/spec/controllers/projects/ci/daily_build_group_report_results_controller_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Projects::Ci::DailyBuildGroupReportResultsController do
+ describe 'GET index' do
+ let(:project) { create(:project, :public, :repository) }
+ let(:ref_path) { 'refs/heads/master' }
+ let(:param_type) { 'coverage' }
+ let(:start_date) { '2019-12-10' }
+ let(:end_date) { '2020-03-09' }
+
+ def create_daily_coverage(group_name, coverage, date)
+ create(
+ :ci_daily_build_group_report_result,
+ project: project,
+ ref_path: ref_path,
+ group_name: group_name,
+ data: { 'coverage' => coverage },
+ date: date
+ )
+ end
+
+ def csv_response
+ CSV.parse(response.body)
+ end
+
+ before do
+ create_daily_coverage('rspec', 79.0, '2020-03-09')
+ create_daily_coverage('karma', 81.0, '2019-12-10')
+ create_daily_coverage('rspec', 67.0, '2019-12-09')
+ create_daily_coverage('karma', 71.0, '2019-12-09')
+
+ get :index, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ ref_path: ref_path,
+ param_type: param_type,
+ start_date: start_date,
+ end_date: end_date,
+ format: :csv
+ }
+ end
+
+ it 'serves the results in CSV' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['Content-Type']).to eq('text/csv; charset=utf-8')
+
+ expect(csv_response).to eq([
+ %w[date group_name coverage],
+ ['2020-03-09', 'rspec', '79.0'],
+ ['2019-12-10', 'karma', '81.0']
+ ])
+ end
+
+ context 'when given date range spans more than 90 days' do
+ let(:start_date) { '2019-12-09' }
+ let(:end_date) { '2020-03-09' }
+
+ it 'limits the result to 90 days from the given start_date' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['Content-Type']).to eq('text/csv; charset=utf-8')
+
+ expect(csv_response).to eq([
+ %w[date group_name coverage],
+ ['2020-03-09', 'rspec', '79.0'],
+ ['2019-12-10', 'karma', '81.0']
+ ])
+ end
+ end
+
+ context 'when given param_type is invalid' do
+ let(:param_type) { 'something_else' }
+
+ it 'responds with 422 error' do
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb
index 07733ec30d9..698a3773d59 100644
--- a/spec/controllers/projects/clusters_controller_spec.rb
+++ b/spec/controllers/projects/clusters_controller_spec.rb
@@ -26,7 +26,7 @@ describe Projects::ClustersController do
let!(:enabled_cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
let!(:disabled_cluster) { create(:cluster, :disabled, :provided_by_gcp, :production_environment, projects: [project]) }
- it 'lists available clusters' do
+ it 'lists available clusters and renders html' do
go
expect(response).to have_gitlab_http_status(:ok)
@@ -34,20 +34,39 @@ describe Projects::ClustersController do
expect(assigns(:clusters)).to match_array([enabled_cluster, disabled_cluster])
end
+ it 'lists available clusters with json serializer' do
+ go(format: :json)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('cluster_list')
+ end
+
context 'when page is specified' do
let(:last_page) { project.clusters.page.total_pages }
+ let(:total_count) { project.clusters.page.total_count }
before do
- allow(Clusters::Cluster).to receive(:paginates_per).and_return(1)
- create_list(:cluster, 2, :provided_by_gcp, :production_environment, projects: [project])
+ create_list(:cluster, 30, :provided_by_gcp, :production_environment, projects: [project])
end
it 'redirects to the page' do
+ expect(last_page).to be > 1
+
go(page: last_page)
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:clusters).current_page).to eq(last_page)
end
+
+ it 'displays cluster list for associated page' do
+ expect(last_page).to be > 1
+
+ go(page: last_page, format: :json)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['X-Page'].to_i).to eq(last_page)
+ expect(response.headers['X-Total'].to_i).to eq(total_count)
+ end
end
end
@@ -68,9 +87,11 @@ describe Projects::ClustersController do
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
+
it 'is disabled for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
+
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_denied_for(:developer).of(project) }
diff --git a/spec/controllers/projects/cycle_analytics/events_controller_spec.rb b/spec/controllers/projects/cycle_analytics/events_controller_spec.rb
index b828c678d0c..942e095d669 100644
--- a/spec/controllers/projects/cycle_analytics/events_controller_spec.rb
+++ b/spec/controllers/projects/cycle_analytics/events_controller_spec.rb
@@ -17,7 +17,7 @@ describe Projects::CycleAnalytics::EventsController do
get_issue
expect(response).to be_successful
- expect(JSON.parse(response.body)['events']).to be_empty
+ expect(Gitlab::Json.parse(response.body)['events']).to be_empty
end
end
@@ -38,7 +38,7 @@ describe Projects::CycleAnalytics::EventsController do
it 'contains event detais' do
get_issue
- events = JSON.parse(response.body)['events']
+ events = Gitlab::Json.parse(response.body)['events']
expect(events).not_to be_empty
expect(events.first).to include('title', 'author', 'iid', 'total_time', 'created_at', 'url')
@@ -51,7 +51,7 @@ describe Projects::CycleAnalytics::EventsController do
expect(response).to be_successful
- expect(JSON.parse(response.body)['events']).to be_empty
+ expect(Gitlab::Json.parse(response.body)['events']).to be_empty
end
end
end
diff --git a/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb b/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb
new file mode 100644
index 00000000000..30d2b79a92f
--- /dev/null
+++ b/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb
@@ -0,0 +1,153 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Projects::DesignManagement::Designs::RawImagesController do
+ include DesignManagementTestHelpers
+
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:viewer) { issue.author }
+ let(:design_id) { design.id }
+ let(:sha) { design.versions.first.sha }
+ let(:filename) { design.filename }
+
+ before do
+ enable_design_management
+ end
+
+ describe 'GET #show' do
+ subject do
+ get(:show,
+ params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ design_id: design_id,
+ sha: sha
+ })
+ end
+
+ before do
+ sign_in(viewer)
+ end
+
+ context 'when the design is not an LFS file' do
+ let_it_be(:design) { create(:design, :with_file, issue: issue, versions_count: 2) }
+
+ # For security, .svg images should only ever be served with Content-Disposition: attachment.
+ # If this specs ever fails we must assess whether we should be serving svg images.
+ # See https://gitlab.com/gitlab-org/gitlab/issues/12771
+ it 'serves files with `Content-Disposition: attachment`' do
+ subject
+
+ expect(response.header['Content-Disposition']).to eq('attachment')
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'serves files with Workhorse' do
+ subject
+
+ expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq "true"
+ expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:')
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it_behaves_like 'project cache control headers'
+
+ context 'when the user does not have permission' do
+ let_it_be(:viewer) { create(:user) }
+
+ specify do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when design does not exist' do
+ let(:design_id) { 'foo' }
+
+ specify do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ describe 'sha param' do
+ let(:newest_version) { design.versions.ordered.first }
+ let(:oldest_version) { design.versions.ordered.last }
+
+ shared_examples 'a successful request for sha' do
+ it do
+ expect_next_instance_of(DesignManagement::Repository) do |repository|
+ expect(repository).to receive(:blob_at).with(expected_ref, design.full_path).and_call_original
+ end
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ specify { expect(newest_version.sha).not_to eq(oldest_version.sha) }
+
+ context 'when sha is the newest version sha' do
+ let(:sha) { newest_version.sha }
+ let(:expected_ref) { sha }
+
+ it_behaves_like 'a successful request for sha'
+ end
+
+ context 'when sha is the oldest version sha' do
+ let(:sha) { oldest_version.sha }
+ let(:expected_ref) { sha }
+
+ it_behaves_like 'a successful request for sha'
+ end
+
+ context 'when sha is nil' do
+ let(:sha) { nil }
+ let(:expected_ref) { 'master' }
+
+ it_behaves_like 'a successful request for sha'
+ end
+ end
+ end
+
+ context 'when the design is an LFS file' do
+ let_it_be(:design) { create(:design, :with_lfs_file, issue: issue) }
+
+ # For security, .svg images should only ever be served with Content-Disposition: attachment.
+ # If this specs ever fails we must assess whether we should be serving svg images.
+ # See https://gitlab.com/gitlab-org/gitlab/issues/12771
+ it 'serves files with `Content-Disposition: attachment`' do
+ subject
+
+ expect(response.header['Content-Disposition']).to eq(%Q(attachment; filename=\"#{filename}\"; filename*=UTF-8''#{filename}))
+ end
+
+ it 'sets appropriate caching headers' do
+ subject
+
+ expect(response.header['ETag']).to be_present
+ expect(response.header['Cache-Control']).to eq("max-age=60, private")
+ end
+ end
+
+ # Pass `skip_lfs_disabled_tests: true` to this shared example to disable
+ # the test scenarios for when LFS is disabled globally.
+ #
+ # When LFS is disabled then the design management feature also becomes disabled.
+ # When the feature is disabled, the `authorize :read_design` check within the
+ # controller will never authorize the user. Therefore #show will return a 403 and
+ # we cannot test the data that it serves.
+ it_behaves_like 'a controller that can serve LFS files', skip_lfs_disabled_tests: true do
+ let(:file) { fixture_file_upload('spec/fixtures/dk.png', '`/png') }
+ let(:lfs_pointer) { Gitlab::Git::LfsPointerFile.new(file.read) }
+ let(:design) { create(:design, :with_lfs_file, file: lfs_pointer.pointer, issue: issue) }
+ let(:lfs_oid) { project.design_repository.blob_at('HEAD', design.full_path).lfs_oid }
+ let(:filepath) { design.full_path }
+ end
+ end
+end
diff --git a/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb b/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb
new file mode 100644
index 00000000000..6bfec1b314e
--- /dev/null
+++ b/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Projects::DesignManagement::Designs::ResizedImageController do
+ include DesignManagementTestHelpers
+
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:viewer) { issue.author }
+ let_it_be(:size) { :v432x230 }
+ let(:design) { create(:design, :with_smaller_image_versions, issue: issue, versions_count: 2) }
+ let(:design_id) { design.id }
+ let(:sha) { design.versions.first.sha }
+
+ before do
+ enable_design_management
+ end
+
+ describe 'GET #show' do
+ subject do
+ get(:show,
+ params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ design_id: design_id,
+ sha: sha,
+ id: size
+ })
+ end
+
+ before do
+ sign_in(viewer)
+ subject
+ end
+
+ context 'when the user does not have permission' do
+ let_it_be(:viewer) { create(:user) }
+
+ specify do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ describe 'Response headers' do
+ it 'completes the request successfully' do
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'sets Content-Disposition as attachment' do
+ filename = design.filename
+
+ expect(response.header['Content-Disposition']).to eq(%Q(attachment; filename=\"#{filename}\"; filename*=UTF-8''#{filename}))
+ end
+
+ it 'serves files with Workhorse' do
+ expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq 'true'
+ end
+
+ it 'sets appropriate caching headers' do
+ expect(response.header['Cache-Control']).to eq('private')
+ expect(response.header['ETag']).to be_present
+ end
+ end
+
+ context 'when design does not exist' do
+ let(:design_id) { 'foo' }
+
+ specify do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when size is invalid' do
+ let_it_be(:size) { :foo }
+
+ it 'returns a 404' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ describe 'sha param' do
+ let(:newest_version) { design.versions.ordered.first }
+ let(:oldest_version) { design.versions.ordered.last }
+
+ # The design images generated by Factorybot are identical, so
+ # refer to the `ETag` header, which is uniquely generated from the Action
+ # (the record that represents the design at a specific version), to
+ # verify that the correct file is being returned.
+ def etag(action)
+ ActionDispatch::TestResponse.new.send(:generate_weak_etag, [action.cache_key, ''])
+ end
+
+ specify { expect(newest_version.sha).not_to eq(oldest_version.sha) }
+
+ context 'when sha is the newest version sha' do
+ let(:sha) { newest_version.sha }
+
+ it 'serves the newest image' do
+ action = newest_version.actions.first
+
+ expect(response.header['ETag']).to eq(etag(action))
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when sha is the oldest version sha' do
+ let(:sha) { oldest_version.sha }
+
+ it 'serves the oldest image' do
+ action = oldest_version.actions.first
+
+ expect(response.header['ETag']).to eq(etag(action))
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when sha is nil' do
+ let(:sha) { nil }
+
+ it 'serves the newest image' do
+ action = newest_version.actions.first
+
+ expect(response.header['ETag']).to eq(etag(action))
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when sha is not a valid version sha' do
+ let(:sha) { '570e7b2abdd848b95f2f578043fc23bd6f6fd24d' }
+
+ it 'returns a 404' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when design does not have a smaller image size available' do
+ let(:design) { create(:design, :with_file, issue: issue) }
+
+ it 'returns a 404' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/environments/prometheus_api_controller_spec.rb b/spec/controllers/projects/environments/prometheus_api_controller_spec.rb
index 793c10f0b21..64f90e44bb6 100644
--- a/spec/controllers/projects/environments/prometheus_api_controller_spec.rb
+++ b/spec/controllers/projects/environments/prometheus_api_controller_spec.rb
@@ -38,7 +38,7 @@ describe Projects::Environments::PrometheusApiController do
context 'with success result' do
let(:service_result) { { status: :success, body: prometheus_body } }
let(:prometheus_body) { '{"status":"success"}' }
- let(:prometheus_json_body) { JSON.parse(prometheus_body) }
+ let(:prometheus_json_body) { Gitlab::Json.parse(prometheus_body) }
it 'returns prometheus response' do
get :proxy, params: environment_params
@@ -55,7 +55,7 @@ describe Projects::Environments::PrometheusApiController do
end
it 'replaces variables with values' do
- get :proxy, params: environment_params.merge(query: 'up{environment="%{ci_environment_slug}"}')
+ get :proxy, params: environment_params.merge(query: 'up{environment="{{ci_environment_slug}}"}')
expect(Prometheus::ProxyService).to have_received(:new)
.with(environment, 'GET', 'query', expected_params)
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb
index 3b035eea7d5..56fff2771ec 100644
--- a/spec/controllers/projects/environments_controller_spec.rb
+++ b/spec/controllers/projects/environments_controller_spec.rb
@@ -410,6 +410,18 @@ describe Projects::EnvironmentsController do
expect(json_response['last_update']).to eq(42)
end
end
+
+ context 'permissions' do
+ before do
+ allow(controller).to receive(:can?).and_return true
+ end
+
+ it 'checks :metrics_dashboard ability' do
+ expect(controller).to receive(:can?).with(anything, :metrics_dashboard, anything)
+
+ get :metrics, params: environment_params
+ end
+ end
end
describe 'GET #additional_metrics' do
@@ -473,6 +485,18 @@ describe Projects::EnvironmentsController do
.to raise_error(ActionController::ParameterMissing)
end
end
+
+ context 'permissions' do
+ before do
+ allow(controller).to receive(:can?).and_return true
+ end
+
+ it 'checks :metrics_dashboard ability' do
+ expect(controller).to receive(:can?).with(anything, :metrics_dashboard, anything)
+
+ get :metrics, params: environment_params
+ end
+ end
end
describe 'GET #metrics_dashboard' do
@@ -648,6 +672,18 @@ describe Projects::EnvironmentsController do
it_behaves_like 'the default dashboard'
it_behaves_like 'dashboard can be specified'
it_behaves_like 'dashboard can be embedded'
+
+ context 'permissions' do
+ before do
+ allow(controller).to receive(:can?).and_return true
+ end
+
+ it 'checks :metrics_dashboard ability' do
+ expect(controller).to receive(:can?).with(anything, :metrics_dashboard, anything)
+
+ get :metrics, params: environment_params
+ end
+ end
end
describe 'GET #search' do
diff --git a/spec/controllers/projects/grafana_api_controller_spec.rb b/spec/controllers/projects/grafana_api_controller_spec.rb
index c62baa30fde..8502bd1ab0a 100644
--- a/spec/controllers/projects/grafana_api_controller_spec.rb
+++ b/spec/controllers/projects/grafana_api_controller_spec.rb
@@ -131,10 +131,11 @@ describe Projects::GrafanaApiController do
get :metrics_dashboard, params: params
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to eq({
+ expect(json_response).to include({
'dashboard' => '{}',
'status' => 'success'
})
+ expect(json_response).to include('metrics_data')
end
end
diff --git a/spec/controllers/projects/graphs_controller_spec.rb b/spec/controllers/projects/graphs_controller_spec.rb
index b5248c7f0c8..e589815c45d 100644
--- a/spec/controllers/projects/graphs_controller_spec.rb
+++ b/spec/controllers/projects/graphs_controller_spec.rb
@@ -41,6 +41,26 @@ describe Projects::GraphsController do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:charts)
end
+
+ it 'sets the daily coverage options' do
+ Timecop.freeze do
+ get(:charts, params: { namespace_id: project.namespace.path, project_id: project.path, id: 'master' })
+
+ expect(assigns[:daily_coverage_options]).to eq(
+ base_params: {
+ start_date: Time.current.to_date - 90.days,
+ end_date: Time.current.to_date,
+ ref_path: project.repository.expand_ref('master'),
+ param_type: 'coverage'
+ },
+ download_path: namespace_project_ci_daily_build_group_report_results_path(
+ namespace_id: project.namespace,
+ project_id: project,
+ format: :csv
+ )
+ )
+ end
+ end
end
context 'when languages were previously detected' do
diff --git a/spec/controllers/projects/import/jira_controller_spec.rb b/spec/controllers/projects/import/jira_controller_spec.rb
index 4629aab65dd..d1b0a086576 100644
--- a/spec/controllers/projects/import/jira_controller_spec.rb
+++ b/spec/controllers/projects/import/jira_controller_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
describe Projects::Import::JiraController do
+ include JiraServiceHelper
+
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:jira_project_key) { 'Test' }
@@ -61,9 +63,10 @@ describe Projects::Import::JiraController do
before do
stub_feature_flags(jira_issue_import: true)
stub_feature_flags(jira_issue_import_vue: false)
+ stub_jira_service_test
end
- context 'when jira service is enabled for the project' do
+ context 'when Jira service is enabled for the project' do
let_it_be(:jira_service) { create(:jira_service, project: project) }
context 'when user is developer' do
@@ -79,7 +82,7 @@ describe Projects::Import::JiraController do
get :show, params: { namespace_id: project.namespace.to_param, project_id: project }
end
- it 'does not query jira service' do
+ it 'does not query Jira service' do
expect(project).not_to receive(:jira_service)
end
@@ -118,7 +121,7 @@ describe Projects::Import::JiraController do
end
end
- context 'when running jira import first time' do
+ context 'when running Jira import first time' do
context 'get show' do
before do
allow(JIRA::Resource::Project).to receive(:all).and_return(jira_projects)
@@ -147,12 +150,12 @@ describe Projects::Import::JiraController do
end
context 'post import' do
- context 'when jira project key is empty' do
+ context 'when Jira project key is empty' do
it 'redirects back to show with an error' do
post :import, params: { namespace_id: project.namespace, project_id: project, jira_project_key: '' }
expect(response).to redirect_to(project_import_jira_path(project))
- expect(flash[:alert]).to eq('No jira project key has been provided.')
+ expect(flash[:alert]).to eq('No Jira project key has been provided.')
end
end
@@ -197,7 +200,7 @@ describe Projects::Import::JiraController do
end
end
- context 'when jira import ran before' do
+ context 'when Jira import ran before' do
let_it_be(:jira_import_state) { create(:jira_import_state, :finished, project: project, jira_project_key: jira_project_key) }
context 'get show' do
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index 862a4bd3559..96f11f11dc4 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
describe Projects::IssuesController do
include ProjectForksHelper
+ include_context 'includes Spam constants'
let(:project) { create(:project) }
let(:user) { create(:user) }
@@ -186,6 +187,33 @@ describe Projects::IssuesController do
expect(assigns(:issue)).to be_a_new(Issue)
end
+ where(:conf_value, :conf_result) do
+ [
+ [true, true],
+ ['true', true],
+ ['TRUE', true],
+ [false, false],
+ ['false', false],
+ ['FALSE', false]
+ ]
+ end
+
+ with_them do
+ it 'sets the confidential flag to the expected value' do
+ get :new, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ issue: {
+ confidential: conf_value
+ }
+ }
+
+ assigned_issue = assigns(:issue)
+ expect(assigned_issue).to be_a_new(Issue)
+ expect(assigned_issue.confidential).to eq conf_result
+ end
+ end
+
it 'fills in an issue for a merge request' do
project_with_repository = create(:project, :repository)
project_with_repository.add_developer(user)
@@ -242,6 +270,91 @@ describe Projects::IssuesController do
end
end
+ describe '#related_branches' do
+ subject { get :related_branches, params: params, format: :json }
+
+ before do
+ sign_in(user)
+ project.add_developer(developer)
+ end
+
+ let(:developer) { user }
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: issue.iid
+ }
+ end
+
+ context 'the current user cannot download code' do
+ it 'prevents access' do
+ allow(controller).to receive(:can?).with(any_args).and_return(true)
+ allow(controller).to receive(:can?).with(user, :download_code, project).and_return(false)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'there are no related branches' do
+ it 'assigns empty arrays', :aggregate_failures do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:related_branches)).to be_empty
+ expect(response).to render_template('projects/issues/_related_branches')
+ expect(json_response).to eq('html' => '')
+ end
+ end
+
+ context 'there are related branches' do
+ let(:missing_branch) { "#{issue.to_branch_name}-missing" }
+ let(:unreadable_branch) { "#{issue.to_branch_name}-unreadable" }
+ let(:pipeline) { build(:ci_pipeline, :success, project: project) }
+ let(:master_branch) { 'master' }
+
+ let(:related_branches) do
+ [
+ branch_info(issue.to_branch_name, pipeline.detailed_status(user)),
+ branch_info(missing_branch, nil),
+ branch_info(unreadable_branch, nil)
+ ]
+ end
+
+ def branch_info(name, status)
+ {
+ name: name,
+ link: controller.project_compare_path(project, from: master_branch, to: name),
+ pipeline_status: status
+ }
+ end
+
+ before do
+ allow(controller).to receive(:find_routable!)
+ .with(Project, project.full_path, any_args).and_return(project)
+ allow(project).to receive(:default_branch).and_return(master_branch)
+ allow_next_instance_of(Issues::RelatedBranchesService) do |service|
+ allow(service).to receive(:execute).and_return(related_branches)
+ end
+ end
+
+ it 'finds and assigns the appropriate branch information', :aggregate_failures do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:related_branches)).to contain_exactly(
+ branch_info(issue.to_branch_name, an_instance_of(Gitlab::Ci::Status::Success)),
+ branch_info(missing_branch, be_nil),
+ branch_info(unreadable_branch, be_nil)
+ )
+ expect(response).to render_template('projects/issues/_related_branches')
+ expect(json_response).to match('html' => String)
+ end
+ end
+ end
+
# This spec runs as a request-style spec in order to invoke the
# Rails router. A controller-style spec matches the wrong route, and
# session['user_return_to'] becomes incorrect.
@@ -419,11 +532,11 @@ describe Projects::IssuesController do
expect(issue.reload.title).to eq('New title')
end
- context 'when Akismet is enabled and the issue is identified as spam' do
+ context 'when the SpamVerdictService disallows' do
before do
stub_application_setting(recaptcha_enabled: true)
- expect_next_instance_of(Spam::AkismetService) do |akismet_service|
- expect(akismet_service).to receive_messages(spam?: true)
+ expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ expect(verdict_service).to receive(:execute).and_return(REQUIRE_RECAPTCHA)
end
end
@@ -496,7 +609,7 @@ describe Projects::IssuesController do
before do
project.add_developer(user)
- issue.update!(last_edited_by: deleted_user, last_edited_at: Time.now)
+ issue.update!(last_edited_by: deleted_user, last_edited_at: Time.current)
deleted_user.destroy
sign_in(user)
@@ -712,20 +825,20 @@ describe Projects::IssuesController do
update_issue(issue_params: { assignee_ids: [assignee.id] })
expect(json_response['assignees'].first.keys)
- .to match_array(%w(id name username avatar_url state web_url))
+ .to include(*%w(id name username avatar_url state web_url))
end
end
- context 'Akismet is enabled' do
+ context 'Recaptcha is enabled' do
before do
project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
stub_application_setting(recaptcha_enabled: true)
end
- context 'when an issue is not identified as spam' do
+ context 'when SpamVerdictService allows the issue' do
before do
- expect_next_instance_of(Spam::AkismetService) do |akismet_service|
- expect(akismet_service).to receive_messages(spam?: false)
+ expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ expect(verdict_service).to receive(:execute).and_return(ALLOW)
end
end
@@ -735,10 +848,10 @@ describe Projects::IssuesController do
end
context 'when an issue is identified as spam' do
- context 'when captcha is not verified' do
+ context 'when recaptcha is not verified' do
before do
- expect_next_instance_of(Spam::AkismetService) do |akismet_service|
- expect(akismet_service).to receive_messages(spam?: true)
+ expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ expect(verdict_service).to receive(:execute).and_return(REQUIRE_RECAPTCHA)
end
end
@@ -751,7 +864,7 @@ describe Projects::IssuesController do
expect { update_issue }.not_to change { issue.reload.title }
end
- it 'rejects an issue recognized as a spam when recaptcha disabled' do
+ it 'rejects an issue recognized as a spam when reCAPTCHA disabled' do
stub_application_setting(recaptcha_enabled: false)
expect { update_issue }.not_to change { issue.reload.title }
@@ -796,7 +909,7 @@ describe Projects::IssuesController do
end
end
- context 'when captcha is verified' do
+ context 'when recaptcha is verified' do
let(:spammy_title) { 'Whatever' }
let!(:spam_logs) { create_list(:spam_log, 2, user: user, title: spammy_title) }
@@ -810,7 +923,7 @@ describe Projects::IssuesController do
expect(response).to have_gitlab_http_status(:ok)
end
- it 'accepts an issue after recaptcha is verified' do
+ it 'accepts an issue after reCAPTCHA is verified' do
expect { update_verified_issue }.to change { issue.reload.title }.to(spammy_title)
end
@@ -967,17 +1080,17 @@ describe Projects::IssuesController do
end
end
- context 'Akismet is enabled' do
+ context 'Recaptcha is enabled' do
before do
stub_application_setting(recaptcha_enabled: true)
end
- context 'when an issue is not identified as spam' do
+ context 'when SpamVerdictService allows the issue' do
before do
stub_feature_flags(allow_possible_spam: false)
- expect_next_instance_of(Spam::AkismetService) do |akismet_service|
- expect(akismet_service).to receive_messages(spam?: false)
+ expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ expect(verdict_service).to receive(:execute).and_return(ALLOW)
end
end
@@ -986,18 +1099,18 @@ describe Projects::IssuesController do
end
end
- context 'when an issue is identified as spam' do
+ context 'when SpamVerdictService requires recaptcha' do
context 'when captcha is not verified' do
- def post_spam_issue
- post_new_issue(title: 'Spam Title', description: 'Spam lives here')
- end
-
before do
- expect_next_instance_of(Spam::AkismetService) do |akismet_service|
- expect(akismet_service).to receive_messages(spam?: true)
+ expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ expect(verdict_service).to receive(:execute).and_return(REQUIRE_RECAPTCHA)
end
end
+ def post_spam_issue
+ post_new_issue(title: 'Spam Title', description: 'Spam lives here')
+ end
+
context 'when allow_possible_spam feature flag is false' do
before do
stub_feature_flags(allow_possible_spam: false)
@@ -1016,7 +1129,7 @@ describe Projects::IssuesController do
expect { post_new_issue(title: '') }.not_to change(Issue, :count)
end
- it 'does not create an issue when recaptcha is not enabled' do
+ it 'does not create an issue when reCAPTCHA is not enabled' do
stub_application_setting(recaptcha_enabled: false)
expect { post_spam_issue }.not_to change(Issue, :count)
@@ -1039,30 +1152,31 @@ describe Projects::IssuesController do
end
end
- context 'when captcha is verified' do
+ context 'when Recaptcha is verified' do
let!(:spam_logs) { create_list(:spam_log, 2, user: user, title: 'Title') }
+ let!(:last_spam_log) { spam_logs.last }
def post_verified_issue
- post_new_issue({}, { spam_log_id: spam_logs.last.id, recaptcha_verification: true } )
+ post_new_issue({}, { spam_log_id: last_spam_log.id, recaptcha_verification: true } )
end
before do
expect(controller).to receive_messages(verify_recaptcha: true)
end
- it 'accepts an issue after recaptcha is verified' do
+ it 'accepts an issue after reCAPTCHA is verified' do
expect { post_verified_issue }.to change(Issue, :count)
end
it 'marks spam log as recaptcha_verified' do
- expect { post_verified_issue }.to change { SpamLog.last.recaptcha_verified }.from(false).to(true)
+ expect { post_verified_issue }.to change { last_spam_log.reload.recaptcha_verified }.from(false).to(true)
end
it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do
spam_log = create(:spam_log)
expect { post_new_issue({}, { spam_log_id: spam_log.id, recaptcha_verification: true } ) }
- .not_to change { SpamLog.last.recaptcha_verified }
+ .not_to change { last_spam_log.recaptcha_verified }
end
end
end
@@ -1294,6 +1408,7 @@ describe Projects::IssuesController do
it 'render merge request as json' do
create_merge_request
+ expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('merge_request')
end
@@ -1337,24 +1452,8 @@ describe Projects::IssuesController do
let(:target_project) { fork_project(project, user, repository: true) }
let(:target_project_id) { target_project.id }
- context 'create_confidential_merge_request feature is enabled' do
- before do
- stub_feature_flags(create_confidential_merge_request: true)
- end
-
- it 'creates a new merge request', :sidekiq_might_not_need_inline do
- expect { create_merge_request }.to change(target_project.merge_requests, :count).by(1)
- end
- end
-
- context 'create_confidential_merge_request feature is disabled' do
- before do
- stub_feature_flags(create_confidential_merge_request: false)
- end
-
- it 'creates a new merge request' do
- expect { create_merge_request }.to change(project.merge_requests, :count).by(1)
- end
+ it 'creates a new merge request', :sidekiq_might_not_need_inline do
+ expect { create_merge_request }.to change(target_project.merge_requests, :count).by(1)
end
end
@@ -1513,61 +1612,6 @@ describe Projects::IssuesController do
expect(note_json['author']['status_tooltip_html']).to be_present
end
- context 'is_gitlab_employee attribute' do
- subject { get :discussions, params: { namespace_id: project.namespace, project_id: project, id: issue.iid } }
-
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- note_user = discussion.author
- note_user.update(email: email)
- note_user.confirm
- end
-
- shared_examples 'non inclusion of gitlab employee badge' do
- it 'does not render the is_gitlab_employee attribute' do
- subject
-
- note_json = json_response.first['notes'].first
-
- expect(note_json['author']['is_gitlab_employee']).to be nil
- end
- end
-
- context 'when user is a gitlab employee' do
- let(:email) { 'test@gitlab.com' }
-
- it 'renders the is_gitlab_employee attribute' do
- subject
-
- note_json = json_response.first['notes'].first
-
- expect(note_json['author']['is_gitlab_employee']).to be true
- end
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(gitlab_employee_badge: false)
- end
-
- it_behaves_like 'non inclusion of gitlab employee badge'
- end
- end
-
- context 'when user is not a gitlab employee' do
- let(:email) { 'test@example.com' }
-
- it_behaves_like 'non inclusion of gitlab employee badge'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(gitlab_employee_badge: false)
- end
-
- it_behaves_like 'non inclusion of gitlab employee badge'
- end
- end
- end
-
it 'does not cause an extra query for the status' do
control = ActiveRecord::QueryRecorder.new do
get :discussions, params: { namespace_id: project.namespace, project_id: project, id: issue.iid }
@@ -1660,6 +1704,33 @@ describe Projects::IssuesController do
end
end
+ describe 'GET #designs' do
+ context 'when project has moved' do
+ let(:new_project) { create(:project) }
+ let(:issue) { create(:issue, project: new_project) }
+
+ before do
+ sign_in(user)
+
+ project.route.destroy
+ new_project.redirect_routes.create!(path: project.full_path)
+ new_project.add_developer(user)
+ end
+
+ it 'redirects from an old issue/designs correctly' do
+ get :designs,
+ params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: issue
+ }
+
+ expect(response).to redirect_to(designs_project_issue_path(new_project, issue))
+ expect(response).to have_gitlab_http_status(:found)
+ end
+ end
+ end
+
context 'private project with token authentication' do
let(:private_project) { create(:project, :private) }
diff --git a/spec/controllers/projects/logs_controller_spec.rb b/spec/controllers/projects/logs_controller_spec.rb
index ea71dbe45aa..e86a42b03c8 100644
--- a/spec/controllers/projects/logs_controller_spec.rb
+++ b/spec/controllers/projects/logs_controller_spec.rb
@@ -16,16 +16,23 @@ describe Projects::LogsController do
let(:container) { 'container-1' }
before do
- project.add_maintainer(user)
-
sign_in(user)
end
describe 'GET #index' do
let(:empty_project) { create(:project) }
+ it 'returns 404 with developer access' do
+ project.add_developer(user)
+
+ get :index, params: environment_params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
it 'renders empty logs page if no environment exists' do
empty_project.add_maintainer(user)
+
get :index, params: { namespace_id: empty_project.namespace, project_id: empty_project }
expect(response).to be_ok
@@ -33,6 +40,8 @@ describe Projects::LogsController do
end
it 'renders index template' do
+ project.add_maintainer(user)
+
get :index, params: environment_params
expect(response).to be_ok
@@ -50,7 +59,7 @@ describe Projects::LogsController do
container_name: container
}
end
- let(:service_result_json) { JSON.parse(service_result.to_json) }
+ let(:service_result_json) { Gitlab::Json.parse(service_result.to_json) }
let_it_be(:cluster) { create(:cluster, :provided_by_gcp, environment_scope: '*', projects: [project]) }
@@ -60,70 +69,84 @@ describe Projects::LogsController do
end
end
- it 'returns the service result' do
+ it 'returns 404 with developer access' do
+ project.add_developer(user)
+
get endpoint, params: environment_params(pod_name: pod_name, format: :json)
- expect(response).to have_gitlab_http_status(:success)
- expect(json_response).to eq(service_result_json)
+ expect(response).to have_gitlab_http_status(:not_found)
end
- it 'registers a usage of the endpoint' do
- expect(::Gitlab::UsageCounters::PodLogs).to receive(:increment).with(project.id)
+ context 'with maintainer access' do
+ before do
+ project.add_maintainer(user)
+ end
- get endpoint, params: environment_params(pod_name: pod_name, format: :json)
+ it 'returns the service result' do
+ get endpoint, params: environment_params(pod_name: pod_name, format: :json)
- expect(response).to have_gitlab_http_status(:success)
- end
+ expect(response).to have_gitlab_http_status(:success)
+ expect(json_response).to eq(service_result_json)
+ end
- it 'sets the polling header' do
- get endpoint, params: environment_params(pod_name: pod_name, format: :json)
+ it 'registers a usage of the endpoint' do
+ expect(::Gitlab::UsageCounters::PodLogs).to receive(:increment).with(project.id)
- expect(response).to have_gitlab_http_status(:success)
- expect(response.headers['Poll-Interval']).to eq('3000')
- end
+ get endpoint, params: environment_params(pod_name: pod_name, format: :json)
- context 'when service is processing' do
- let(:service_result) { nil }
+ expect(response).to have_gitlab_http_status(:success)
+ end
- it 'returns a 202' do
+ it 'sets the polling header' do
get endpoint, params: environment_params(pod_name: pod_name, format: :json)
- expect(response).to have_gitlab_http_status(:accepted)
+ expect(response).to have_gitlab_http_status(:success)
+ expect(response.headers['Poll-Interval']).to eq('3000')
end
- end
- shared_examples 'unsuccessful execution response' do |message|
- let(:service_result) do
- {
- status: :error,
- message: message
- }
- end
+ context 'when service is processing' do
+ let(:service_result) { nil }
- it 'returns the error' do
- get endpoint, params: environment_params(pod_name: pod_name, format: :json)
+ it 'returns a 202' do
+ get endpoint, params: environment_params(pod_name: pod_name, format: :json)
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response).to eq(service_result_json)
+ expect(response).to have_gitlab_http_status(:accepted)
+ end
end
- end
- context 'when service is failing' do
- it_behaves_like 'unsuccessful execution response', 'some error'
- end
+ shared_examples 'unsuccessful execution response' do |message|
+ let(:service_result) do
+ {
+ status: :error,
+ message: message
+ }
+ end
- context 'when cluster is nil' do
- let!(:cluster) { nil }
+ it 'returns the error' do
+ get endpoint, params: environment_params(pod_name: pod_name, format: :json)
- it_behaves_like 'unsuccessful execution response', 'Environment does not have deployments'
- end
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq(service_result_json)
+ end
+ end
- context 'when namespace is empty' do
- before do
- allow(environment).to receive(:deployment_namespace).and_return('')
+ context 'when service is failing' do
+ it_behaves_like 'unsuccessful execution response', 'some error'
+ end
+
+ context 'when cluster is nil' do
+ let!(:cluster) { nil }
+
+ it_behaves_like 'unsuccessful execution response', 'Environment does not have deployments'
end
- it_behaves_like 'unsuccessful execution response', 'Environment does not have deployments'
+ context 'when namespace is empty' do
+ before do
+ allow(environment).to receive(:deployment_namespace).and_return('')
+ end
+
+ it_behaves_like 'unsuccessful execution response', 'Environment does not have deployments'
+ end
end
end
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index aaeaf53d100..7d9e42fcc2d 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -935,34 +935,6 @@ describe Projects::MergeRequestsController do
}])
end
end
-
- context 'when feature flag :ci_expose_arbitrary_artifacts_in_mr is disabled' do
- let(:job_options) do
- {
- artifacts: {
- paths: ['ci_artifacts.txt'],
- expose_as: 'Exposed artifact'
- }
- }
- end
- let(:report) { double }
-
- before do
- stub_feature_flags(ci_expose_arbitrary_artifacts_in_mr: false)
- end
-
- it 'does not send polling interval' do
- expect(Gitlab::PollingInterval).not_to receive(:set_header)
-
- subject
- end
-
- it 'returns 204 HTTP status' do
- subject
-
- expect(response).to have_gitlab_http_status(:no_content)
- end
- end
end
context 'when pipeline does not have jobs with exposed artifacts' do
@@ -1114,6 +1086,150 @@ describe Projects::MergeRequestsController do
end
end
+ describe 'GET terraform_reports' do
+ let(:merge_request) do
+ create(:merge_request,
+ :with_merge_request_pipeline,
+ target_project: project,
+ source_project: project)
+ end
+
+ let(:pipeline) do
+ create(:ci_pipeline,
+ :success,
+ :with_terraform_reports,
+ project: merge_request.source_project,
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha)
+ end
+
+ before do
+ allow_any_instance_of(MergeRequest)
+ .to receive(:find_terraform_reports)
+ .and_return(report)
+
+ allow_any_instance_of(MergeRequest)
+ .to receive(:actual_head_pipeline)
+ .and_return(pipeline)
+ end
+
+ subject do
+ get :terraform_reports, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: merge_request.iid
+ },
+ format: :json
+ end
+
+ describe 'permissions on a public project with private CI/CD' do
+ let(:project) { create :project, :repository, :public, :builds_private }
+ let(:report) { { status: :parsed, data: [] } }
+
+ context 'while signed out' do
+ before do
+ sign_out(user)
+ end
+
+ it 'responds with a 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(response.body).to be_blank
+ end
+ end
+
+ context 'while signed in as an unrelated user' do
+ before do
+ sign_in(create(:user))
+ end
+
+ it 'responds with a 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(response.body).to be_blank
+ end
+ end
+ end
+
+ context 'when pipeline has jobs with terraform reports' do
+ before do
+ allow_next_instance_of(MergeRequest) do |merge_request|
+ allow(merge_request).to receive(:has_terraform_reports?).and_return(true)
+ end
+ end
+
+ context 'when processing terraform reports is in progress' do
+ let(:report) { { status: :parsing } }
+
+ it 'sends polling interval' do
+ expect(Gitlab::PollingInterval).to receive(:set_header)
+
+ subject
+ end
+
+ it 'returns 204 HTTP status' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ context 'when processing terraform reports is completed' do
+ let(:report) { { status: :parsed, data: pipeline.terraform_reports.plans } }
+
+ it 'returns terraform reports' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to match(
+ a_hash_including(
+ 'tfplan.json' => hash_including(
+ 'create' => 0,
+ 'delete' => 0,
+ 'update' => 1
+ )
+ )
+ )
+ end
+ end
+
+ context 'when user created corrupted terraform reports' do
+ let(:report) { { status: :error, status_reason: 'Failed to parse terraform reports' } }
+
+ it 'does not send polling interval' do
+ expect(Gitlab::PollingInterval).not_to receive(:set_header)
+
+ subject
+ end
+
+ it 'returns 400 HTTP status' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq({ 'status_reason' => 'Failed to parse terraform reports' })
+ end
+ end
+ end
+
+ context 'when pipeline does not have jobs with terraform reports' do
+ before do
+ allow_next_instance_of(MergeRequest) do |merge_request|
+ allow(merge_request).to receive(:has_terraform_reports?).and_return(false)
+ end
+ end
+
+ let(:report) { { status: :error } }
+
+ it 'returns error' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+
describe 'GET test_reports' do
let(:merge_request) do
create(:merge_request,
@@ -1225,6 +1341,141 @@ describe Projects::MergeRequestsController do
end
end
+ describe 'GET accessibility_reports' do
+ let(:merge_request) do
+ create(:merge_request,
+ :with_diffs,
+ :with_merge_request_pipeline,
+ target_project: project,
+ source_project: project
+ )
+ end
+
+ let(:pipeline) do
+ create(:ci_pipeline,
+ :success,
+ project: merge_request.source_project,
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha)
+ end
+
+ before do
+ allow_any_instance_of(MergeRequest)
+ .to receive(:compare_accessibility_reports)
+ .and_return(accessibility_comparison)
+
+ allow_any_instance_of(MergeRequest)
+ .to receive(:actual_head_pipeline)
+ .and_return(pipeline)
+ end
+
+ subject do
+ get :accessibility_reports, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: merge_request.iid
+ },
+ format: :json
+ end
+
+ context 'permissions on a public project with private CI/CD' do
+ let(:project) { create(:project, :repository, :public, :builds_private) }
+ let(:accessibility_comparison) { { status: :parsed, data: { summary: 1 } } }
+
+ context 'while signed out' do
+ before do
+ sign_out(user)
+ end
+
+ it 'responds with a 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(response.body).to be_blank
+ end
+ end
+
+ context 'while signed in as an unrelated user' do
+ before do
+ sign_in(create(:user))
+ end
+
+ it 'responds with a 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(response.body).to be_blank
+ end
+ end
+ end
+
+ context 'when feature flag is disabled' do
+ let(:accessibility_comparison) { { status: :parsed, data: { summary: 1 } } }
+
+ before do
+ stub_feature_flags(accessibility_report_view: false)
+ end
+
+ it 'returns 204 HTTP status' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ context 'when pipeline has jobs with accessibility reports' do
+ before do
+ allow_any_instance_of(MergeRequest)
+ .to receive(:has_accessibility_reports?)
+ .and_return(true)
+ end
+
+ context 'when processing accessibility reports is in progress' do
+ let(:accessibility_comparison) { { status: :parsing } }
+
+ it 'sends polling interval' do
+ expect(Gitlab::PollingInterval).to receive(:set_header)
+
+ subject
+ end
+
+ it 'returns 204 HTTP status' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ context 'when processing accessibility reports is completed' do
+ let(:accessibility_comparison) { { status: :parsed, data: { summary: 1 } } }
+
+ it 'returns accessibility reports' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq({ 'summary' => 1 })
+ end
+ end
+
+ context 'when user created corrupted accessibility reports' do
+ let(:accessibility_comparison) { { status: :error, status_reason: 'This merge request does not have accessibility reports' } }
+
+ it 'does not send polling interval' do
+ expect(Gitlab::PollingInterval).not_to receive(:set_header)
+
+ subject
+ end
+
+ it 'returns 400 HTTP status' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq({ 'status_reason' => 'This merge request does not have accessibility reports' })
+ end
+ end
+ end
+ end
+
describe 'POST remove_wip' do
before do
merge_request.title = merge_request.wip_title
diff --git a/spec/controllers/projects/mirrors_controller_spec.rb b/spec/controllers/projects/mirrors_controller_spec.rb
index faeade0d737..8cd940978c0 100644
--- a/spec/controllers/projects/mirrors_controller_spec.rb
+++ b/spec/controllers/projects/mirrors_controller_spec.rb
@@ -189,7 +189,7 @@ describe Projects::MirrorsController do
context 'no data in cache' do
it 'requests the cache to be filled and returns a 204 response' do
- expect(ReactiveCachingWorker).to receive(:perform_async).with(cache.class, cache.id).at_least(:once)
+ expect(ExternalServiceReactiveCachingWorker).to receive(:perform_async).with(cache.class, cache.id).at_least(:once)
do_get(project)
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index 9d243bf5a7f..b3d8fb94fb3 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -145,11 +145,81 @@ describe Projects::PipelinesController do
end
end
- def get_pipelines_index_json
+ context 'filter by scope' do
+ it 'returns matched pipelines' do
+ get_pipelines_index_json(scope: 'running')
+
+ check_pipeline_response(returned: 2, all: 6, running: 2, pending: 1, finished: 3)
+ end
+
+ context 'scope is branches or tags' do
+ before do
+ create(:ci_pipeline, :failed, project: project, ref: 'v1.0.0', tag: true)
+ end
+
+ context 'when scope is branches' do
+ it 'returns matched pipelines' do
+ get_pipelines_index_json(scope: 'branches')
+
+ check_pipeline_response(returned: 1, all: 7, running: 2, pending: 1, finished: 4)
+ end
+ end
+
+ context 'when scope is tags' do
+ it 'returns matched pipelines' do
+ get_pipelines_index_json(scope: 'tags')
+
+ check_pipeline_response(returned: 1, all: 7, running: 2, pending: 1, finished: 4)
+ end
+ end
+ end
+ end
+
+ context 'filter by username' do
+ let!(:pipeline) { create(:ci_pipeline, :running, project: project, user: user) }
+
+ context 'when username exists' do
+ it 'returns matched pipelines' do
+ get_pipelines_index_json(username: user.username)
+
+ check_pipeline_response(returned: 1, all: 1, running: 1, pending: 0, finished: 0)
+ end
+ end
+
+ context 'when username does not exist' do
+ it 'returns empty' do
+ get_pipelines_index_json(username: 'invalid-username')
+
+ check_pipeline_response(returned: 0, all: 0, running: 0, pending: 0, finished: 0)
+ end
+ end
+ end
+
+ context 'filter by ref' do
+ let!(:pipeline) { create(:ci_pipeline, :running, project: project, ref: 'branch-1') }
+
+ context 'when pipelines with the ref exists' do
+ it 'returns matched pipelines' do
+ get_pipelines_index_json(ref: 'branch-1')
+
+ check_pipeline_response(returned: 1, all: 1, running: 1, pending: 0, finished: 0)
+ end
+ end
+
+ context 'when no pipeline with the ref exists' do
+ it 'returns empty list' do
+ get_pipelines_index_json(ref: 'invalid-ref')
+
+ check_pipeline_response(returned: 0, all: 0, running: 0, pending: 0, finished: 0)
+ end
+ end
+ end
+
+ def get_pipelines_index_json(params = {})
get :index, params: {
namespace_id: project.namespace,
project_id: project
- },
+ }.merge(params),
format: :json
end
@@ -199,6 +269,18 @@ describe Projects::PipelinesController do
user: user
)
end
+
+ def check_pipeline_response(returned:, all:, running:, pending:, finished:)
+ aggregate_failures do
+ expect(response).to match_response_schema('pipeline')
+
+ expect(json_response['pipelines'].count).to eq returned
+ expect(json_response['count']['all'].to_i).to eq all
+ expect(json_response['count']['running'].to_i).to eq running
+ expect(json_response['count']['pending'].to_i).to eq pending
+ expect(json_response['count']['finished'].to_i).to eq finished
+ end
+ end
end
describe 'GET show.json' do
@@ -748,12 +830,10 @@ describe Projects::PipelinesController do
context 'when feature is enabled' do
before do
- stub_feature_flags(junit_pipeline_view: true)
+ stub_feature_flags(junit_pipeline_view: project)
end
context 'when pipeline does not have a test report' do
- let(:pipeline) { create(:ci_pipeline, project: project) }
-
it 'renders an empty test report' do
get_test_report_json
@@ -763,7 +843,11 @@ describe Projects::PipelinesController do
end
context 'when pipeline has a test report' do
- let(:pipeline) { create(:ci_pipeline, :with_test_reports, project: project) }
+ before do
+ create(:ci_build, name: 'rspec', pipeline: pipeline).tap do |build|
+ create(:ci_job_artifact, :junit, job: build)
+ end
+ end
it 'renders the test report' do
get_test_report_json
@@ -773,25 +857,28 @@ describe Projects::PipelinesController do
end
end
- context 'when pipeline has corrupt test reports' do
- let(:pipeline) { create(:ci_pipeline, project: project) }
-
+ context 'when pipeline has a corrupt test report artifact' do
before do
- job = create(:ci_build, pipeline: pipeline)
- create(:ci_job_artifact, :junit_with_corrupted_data, job: job, project: project)
- end
+ create(:ci_build, name: 'rspec', pipeline: pipeline).tap do |build|
+ create(:ci_job_artifact, :junit_with_corrupted_data, job: build)
+ end
- it 'renders the test reports' do
get_test_report_json
+ end
+ it 'renders the test reports' do
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['status']).to eq('error_parsing_report')
+ expect(json_response['test_suites'].count).to eq(1)
+ end
+
+ it 'returns a suite_error on the suite with corrupted XML' do
+ expect(json_response['test_suites'].first['suite_error']).to eq('JUnit XML parsing failed: 1:1: FATAL: Document is empty')
end
end
context 'when junit_pipeline_screenshots_view is enabled' do
before do
- stub_feature_flags(junit_pipeline_screenshots_view: { enabled: true, thing: project })
+ stub_feature_flags(junit_pipeline_screenshots_view: project)
end
context 'when test_report contains attachment and scope is with_attachment as a URL param' do
@@ -820,7 +907,7 @@ describe Projects::PipelinesController do
context 'when junit_pipeline_screenshots_view is disabled' do
before do
- stub_feature_flags(junit_pipeline_screenshots_view: { enabled: false, thing: project })
+ stub_feature_flags(junit_pipeline_screenshots_view: false)
end
context 'when test_report contains attachment and scope is with_attachment as a URL param' do
diff --git a/spec/controllers/projects/prometheus/alerts_controller_spec.rb b/spec/controllers/projects/prometheus/alerts_controller_spec.rb
index 451834e0962..e936cb5916e 100644
--- a/spec/controllers/projects/prometheus/alerts_controller_spec.rb
+++ b/spec/controllers/projects/prometheus/alerts_controller_spec.rb
@@ -352,7 +352,7 @@ describe Projects::Prometheus::AlertsController do
get :metrics_dashboard, params: request_params(id: metric.id, environment_id: alert.environment.id), format: :json
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.keys).to contain_exactly('dashboard', 'status')
+ expect(json_response.keys).to contain_exactly('dashboard', 'status', 'metrics_data')
end
it 'is the correct embed' do
diff --git a/spec/controllers/projects/refs_controller_spec.rb b/spec/controllers/projects/refs_controller_spec.rb
index 646c7a7db7c..b043e7f2538 100644
--- a/spec/controllers/projects/refs_controller_spec.rb
+++ b/spec/controllers/projects/refs_controller_spec.rb
@@ -12,25 +12,27 @@ describe Projects::RefsController do
end
describe 'GET #logs_tree' do
+ let(:path) { 'foo/bar/baz.html' }
+
def default_get(format = :html)
get :logs_tree,
params: {
namespace_id: project.namespace.to_param,
project_id: project,
id: 'master',
- path: 'foo/bar/baz.html'
+ path: path
},
format: format
end
- def xhr_get(format = :html)
+ def xhr_get(format = :html, params = {})
get :logs_tree, params: {
namespace_id: project.namespace.to_param,
project_id: project,
id: 'master',
- path: 'foo/bar/baz.html',
+ path: path,
format: format
- }, xhr: true
+ }.merge(params), xhr: true
end
it 'never throws MissingTemplate' do
@@ -52,13 +54,27 @@ describe Projects::RefsController do
expect(response).to be_successful
end
- it 'renders JSON' do
- expect(::Gitlab::GitalyClient).to receive(:allow_ref_name_caching).and_call_original
+ context 'when json is requested' do
+ it 'renders JSON' do
+ expect(::Gitlab::GitalyClient).to receive(:allow_ref_name_caching).and_call_original
- xhr_get(:json)
+ xhr_get(:json)
- expect(response).to be_successful
- expect(json_response).to be_kind_of(Array)
+ expect(response).to be_successful
+ expect(json_response).to be_kind_of(Array)
+ end
+
+ it 'caches tree summary data', :use_clean_rails_memory_store_caching do
+ expect_next_instance_of(::Gitlab::TreeSummary) do |instance|
+ expect(instance).to receive_messages(summarize: ['logs'], next_offset: 50, more?: true)
+ end
+
+ xhr_get(:json, offset: 25)
+
+ cache_key = "projects/#{project.id}/logs/#{project.commit.id}/#{path}/25"
+ expect(Rails.cache.fetch(cache_key)).to eq(['logs', 50])
+ expect(response.headers['More-Logs-Offset']).to eq(50)
+ end
end
end
end
diff --git a/spec/controllers/projects/registry/repositories_controller_spec.rb b/spec/controllers/projects/registry/repositories_controller_spec.rb
index c641a45a216..badb84f9b50 100644
--- a/spec/controllers/projects/registry/repositories_controller_spec.rb
+++ b/spec/controllers/projects/registry/repositories_controller_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
describe Projects::Registry::RepositoriesController do
- let(:user) { create(:user) }
- let(:project) { create(:project, :private) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :private) }
before do
sign_in(user)
@@ -16,6 +16,22 @@ describe Projects::Registry::RepositoriesController do
project.add_developer(user)
end
+ shared_examples 'with name parameter' do
+ let_it_be(:repo) { create(:container_repository, project: project, name: 'my_searched_image') }
+ let_it_be(:another_repo) { create(:container_repository, project: project, name: 'bar') }
+
+ it 'returns the searched repo' do
+ go_to_index(format: :json, params: { name: 'my_searched_image' })
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.length).to eq 1
+ expect(json_response.first).to include(
+ 'id' => repo.id,
+ 'name' => repo.name
+ )
+ end
+ end
+
shared_examples 'renders a list of repositories' do
context 'when root container repository exists' do
before do
@@ -60,6 +76,8 @@ describe Projects::Registry::RepositoriesController do
expect(response).to match_response_schema('registry/repositories')
expect(response).to include_pagination_headers
end
+
+ it_behaves_like 'with name parameter'
end
context 'when there are no tags for this repository' do
@@ -138,11 +156,11 @@ describe Projects::Registry::RepositoriesController do
end
end
- def go_to_index(format: :html)
- get :index, params: {
+ def go_to_index(format: :html, params: {} )
+ get :index, params: params.merge({
namespace_id: project.namespace,
project_id: project
- },
+ }),
format: format
end
diff --git a/spec/controllers/projects/service_hook_logs_controller_spec.rb b/spec/controllers/projects/service_hook_logs_controller_spec.rb
index ca57b0579a8..a5130cd6e32 100644
--- a/spec/controllers/projects/service_hook_logs_controller_spec.rb
+++ b/spec/controllers/projects/service_hook_logs_controller_spec.rb
@@ -24,7 +24,7 @@ describe Projects::ServiceHookLogsController do
describe 'GET #show' do
subject { get :show, params: log_params }
- it do
+ specify do
expect(response).to be_successful
end
end
diff --git a/spec/controllers/projects/settings/access_tokens_controller_spec.rb b/spec/controllers/projects/settings/access_tokens_controller_spec.rb
new file mode 100644
index 00000000000..884a5bc2836
--- /dev/null
+++ b/spec/controllers/projects/settings/access_tokens_controller_spec.rb
@@ -0,0 +1,190 @@
+# frozen_string_literal: true
+
+require('spec_helper')
+
+describe Projects::Settings::AccessTokensController do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+
+ before_all do
+ project.add_maintainer(user)
+ end
+
+ before do
+ sign_in(user)
+ end
+
+ shared_examples 'feature unavailability' do
+ context 'when flag is disabled' do
+ before do
+ stub_feature_flags(resource_access_token: false)
+ end
+
+ it { is_expected.to have_gitlab_http_status(:not_found) }
+ end
+
+ context 'when environment is Gitlab.com' do
+ before do
+ allow(Gitlab).to receive(:com?).and_return(true)
+ end
+
+ it { is_expected.to have_gitlab_http_status(:not_found) }
+ end
+ end
+
+ describe '#index' do
+ subject { get :index, params: { namespace_id: project.namespace, project_id: project } }
+
+ it_behaves_like 'feature unavailability'
+
+ context 'when feature is available' do
+ let_it_be(:bot_user) { create(:user, :project_bot) }
+ let_it_be(:active_project_access_token) { create(:personal_access_token, user: bot_user) }
+ let_it_be(:inactive_project_access_token) { create(:personal_access_token, :revoked, user: bot_user) }
+
+ before_all do
+ project.add_maintainer(bot_user)
+ end
+
+ before do
+ enable_feature
+ end
+
+ it 'retrieves active project access tokens' do
+ subject
+
+ expect(assigns(:active_project_access_tokens)).to contain_exactly(active_project_access_token)
+ end
+
+ it 'retrieves inactive project access tokens' do
+ subject
+
+ expect(assigns(:inactive_project_access_tokens)).to contain_exactly(inactive_project_access_token)
+ end
+
+ it 'lists all available scopes' do
+ subject
+
+ expect(assigns(:scopes)).to eq(Gitlab::Auth.resource_bot_scopes)
+ end
+
+ it 'retrieves newly created personal access token value' do
+ token_value = 'random-value'
+ allow(PersonalAccessToken).to receive(:redis_getdel).with("#{user.id}:#{project.id}").and_return(token_value)
+
+ subject
+
+ expect(assigns(:new_project_access_token)).to eq(token_value)
+ end
+ end
+ end
+
+ describe '#create', :clean_gitlab_redis_shared_state do
+ subject { post :create, params: { namespace_id: project.namespace, project_id: project }.merge(project_access_token: access_token_params) }
+
+ let_it_be(:access_token_params) { {} }
+
+ it_behaves_like 'feature unavailability'
+
+ context 'when feature is available' do
+ let_it_be(:access_token_params) { { name: 'Nerd bot', scopes: ["api"], expires_at: 1.month.since.to_date } }
+
+ before do
+ enable_feature
+ end
+
+ def created_token
+ PersonalAccessToken.order(:created_at).last
+ end
+
+ it 'returns success message' do
+ subject
+
+ expect(response.flash[:notice]).to match(/\AYour new project access token has been created./i)
+ end
+
+ it 'creates project access token' do
+ subject
+
+ expect(created_token.name).to eq(access_token_params[:name])
+ expect(created_token.scopes).to eq(access_token_params[:scopes])
+ expect(created_token.expires_at).to eq(access_token_params[:expires_at])
+ end
+
+ it 'creates project bot user' do
+ subject
+
+ expect(created_token.user).to be_project_bot
+ end
+
+ it 'stores newly created token redis store' do
+ expect(PersonalAccessToken).to receive(:redis_store!)
+
+ subject
+ end
+
+ it { expect { subject }.to change { User.count }.by(1) }
+ it { expect { subject }.to change { PersonalAccessToken.count }.by(1) }
+
+ context 'when unsuccessful' do
+ before do
+ allow_next_instance_of(ResourceAccessTokens::CreateService) do |service|
+ allow(service).to receive(:execute).and_return ServiceResponse.error(message: 'Failed!')
+ end
+ end
+
+ it { expect(subject).to render_template(:index) }
+ end
+ end
+ end
+
+ describe '#revoke' do
+ subject { put :revoke, params: { namespace_id: project.namespace, project_id: project, id: project_access_token } }
+
+ let_it_be(:bot_user) { create(:user, :project_bot) }
+ let_it_be(:project_access_token) { create(:personal_access_token, user: bot_user) }
+
+ before_all do
+ project.add_maintainer(bot_user)
+ end
+
+ it_behaves_like 'feature unavailability'
+
+ context 'when feature is available' do
+ before do
+ enable_feature
+ end
+
+ it 'revokes token access' do
+ subject
+
+ expect(project_access_token.reload.revoked?).to be true
+ end
+
+ it 'removed membership of bot user' do
+ subject
+
+ expect(project.reload.bots).not_to include(bot_user)
+ end
+
+ it 'blocks project bot user' do
+ subject
+
+ expect(bot_user.reload.blocked?).to be true
+ end
+
+ it 'converts issuables of the bot user to ghost user' do
+ issue = create(:issue, author: bot_user)
+
+ subject
+
+ expect(issue.reload.author.ghost?).to be true
+ end
+ end
+ end
+
+ def enable_feature
+ allow(Gitlab).to receive(:com?).and_return(false)
+ stub_feature_flags(resource_access_token: true)
+ end
+end
diff --git a/spec/controllers/projects/settings/repository_controller_spec.rb b/spec/controllers/projects/settings/repository_controller_spec.rb
index 847c80e8917..fb9cdd860dc 100644
--- a/spec/controllers/projects/settings/repository_controller_spec.rb
+++ b/spec/controllers/projects/settings/repository_controller_spec.rb
@@ -36,7 +36,7 @@ describe Projects::Settings::RepositoryController do
describe 'POST create_deploy_token' do
context 'when ajax_new_deploy_token feature flag is disabled for the project' do
before do
- stub_feature_flags(ajax_new_deploy_token: { enabled: false, thing: project })
+ stub_feature_flags(ajax_new_deploy_token: false)
end
it_behaves_like 'a created deploy token' do
@@ -73,7 +73,7 @@ describe Projects::Settings::RepositoryController do
'id' => be_a(Integer),
'name' => deploy_token_params[:name],
'username' => deploy_token_params[:username],
- 'expires_at' => Time.parse(deploy_token_params[:expires_at]),
+ 'expires_at' => Time.zone.parse(deploy_token_params[:expires_at]),
'token' => be_a(String),
'scopes' => deploy_token_params.inject([]) do |scopes, kv|
key, value = kv
diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb
index 284789305e2..b5f4929d8ce 100644
--- a/spec/controllers/projects/snippets_controller_spec.rb
+++ b/spec/controllers/projects/snippets_controller_spec.rb
@@ -127,7 +127,7 @@ describe Projects::SnippetsController do
.to log_spam(title: 'Title', user_id: user.id, noteable_type: 'ProjectSnippet')
end
- it 'renders :new with recaptcha disabled' do
+ it 'renders :new with reCAPTCHA disabled' do
stub_application_setting(recaptcha_enabled: false)
create_snippet(project, visibility_level: Snippet::PUBLIC)
@@ -135,18 +135,18 @@ describe Projects::SnippetsController do
expect(response).to render_template(:new)
end
- context 'recaptcha enabled' do
+ context 'reCAPTCHA enabled' do
before do
stub_application_setting(recaptcha_enabled: true)
end
- it 'renders :verify with recaptcha enabled' do
+ it 'renders :verify with reCAPTCHA enabled' do
create_snippet(project, visibility_level: Snippet::PUBLIC)
expect(response).to render_template(:verify)
end
- it 'renders snippet page when recaptcha verified' do
+ it 'renders snippet page when reCAPTCHA verified' do
spammy_title = 'Whatever'
spam_logs = create_list(:spam_log, 2, user: user, title: spammy_title)
@@ -223,7 +223,7 @@ describe Projects::SnippetsController do
.to log_spam(title: 'Foo', user_id: user.id, noteable_type: 'ProjectSnippet')
end
- it 'renders :edit with recaptcha disabled' do
+ it 'renders :edit with reCAPTCHA disabled' do
stub_application_setting(recaptcha_enabled: false)
update_snippet(title: 'Foo')
@@ -231,18 +231,18 @@ describe Projects::SnippetsController do
expect(response).to render_template(:edit)
end
- context 'recaptcha enabled' do
+ context 'reCAPTCHA enabled' do
before do
stub_application_setting(recaptcha_enabled: true)
end
- it 'renders :verify with recaptcha enabled' do
+ it 'renders :verify with reCAPTCHA enabled' do
update_snippet(title: 'Foo')
expect(response).to render_template(:verify)
end
- it 'renders snippet page when recaptcha verified' do
+ it 'renders snippet page when reCAPTCHA verified' do
spammy_title = 'Whatever'
spam_logs = create_list(:spam_log, 2, user: user, title: spammy_title)
@@ -268,7 +268,7 @@ describe Projects::SnippetsController do
.to log_spam(title: 'Foo', user_id: user.id, noteable_type: 'ProjectSnippet')
end
- it 'renders :edit with recaptcha disabled' do
+ it 'renders :edit with reCAPTCHA disabled' do
stub_application_setting(recaptcha_enabled: false)
update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC)
@@ -276,18 +276,18 @@ describe Projects::SnippetsController do
expect(response).to render_template(:edit)
end
- context 'recaptcha enabled' do
+ context 'reCAPTCHA enabled' do
before do
stub_application_setting(recaptcha_enabled: true)
end
- it 'renders :verify with recaptcha enabled' do
+ it 'renders :verify' do
update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC)
expect(response).to render_template(:verify)
end
- it 'renders snippet page when recaptcha verified' do
+ it 'renders snippet page' do
spammy_title = 'Whatever'
spam_logs = create_list(:spam_log, 2, user: user, title: spammy_title)
@@ -346,20 +346,6 @@ describe Projects::SnippetsController do
expect(assigns(:blob)).to eq(project_snippet.blobs.first)
end
-
- context 'when feature flag version_snippets is disabled' do
- before do
- stub_feature_flags(version_snippets: false)
- end
-
- it 'returns the snippet database content' do
- subject
-
- blob = assigns(:blob)
-
- expect(blob.data).to eq(project_snippet.content)
- end
- end
end
%w[show raw].each do |action|
diff --git a/spec/controllers/projects/static_site_editor_controller_spec.rb b/spec/controllers/projects/static_site_editor_controller_spec.rb
index f7c8848b8cf..7b470254de1 100644
--- a/spec/controllers/projects/static_site_editor_controller_spec.rb
+++ b/spec/controllers/projects/static_site_editor_controller_spec.rb
@@ -3,7 +3,8 @@
require 'spec_helper'
describe Projects::StaticSiteEditorController do
- let(:project) { create(:project, :public, :repository) }
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:user) { create(:user) }
describe 'GET show' do
let(:default_params) do
@@ -27,8 +28,6 @@ describe Projects::StaticSiteEditorController do
end
context 'as guest' do
- let(:user) { create(:user) }
-
before do
project.add_guest(user)
sign_in(user)
@@ -42,10 +41,11 @@ describe Projects::StaticSiteEditorController do
%w[developer maintainer].each do |role|
context "as #{role}" do
- let(:user) { create(:user) }
+ before_all do
+ project.add_role(user, role)
+ end
before do
- project.add_role(user, role)
sign_in(user)
get :show, params: default_params
end
@@ -54,8 +54,10 @@ describe Projects::StaticSiteEditorController do
expect(response).to render_template(:show)
end
- it 'assigns a config variable' do
+ it 'assigns a required variables' do
expect(assigns(:config)).to be_a(Gitlab::StaticSiteEditor::Config)
+ 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
diff --git a/spec/controllers/projects/usage_ping_controller_spec.rb b/spec/controllers/projects/usage_ping_controller_spec.rb
index 284db93d7a8..a68967c228f 100644
--- a/spec/controllers/projects/usage_ping_controller_spec.rb
+++ b/spec/controllers/projects/usage_ping_controller_spec.rb
@@ -6,45 +6,52 @@ describe Projects::UsagePingController do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
- describe 'POST #web_ide_clientside_preview' do
- subject { post :web_ide_clientside_preview, params: { namespace_id: project.namespace, project_id: project } }
+ before do
+ sign_in(user) if user
+ end
- before do
- sign_in(user) if user
- end
+ shared_examples 'counter is not increased' do
+ context 'when the user is not authenticated' do
+ let(:user) { nil }
- context 'when web ide clientside preview is enabled' do
- before do
- stub_application_setting(web_ide_clientside_preview_enabled: true)
- end
+ it 'returns 302' do
+ subject
- context 'when the user is not authenticated' do
- let(:user) { nil }
+ expect(response).to have_gitlab_http_status(:found)
+ end
+ end
- it 'returns 302' do
- subject
+ context 'when the user does not have access to the project' do
+ it 'returns 404' do
+ subject
- expect(response).to have_gitlab_http_status(:found)
- end
+ expect(response).to have_gitlab_http_status(:not_found)
end
+ end
+ end
- context 'when the user does not have access to the project' do
- it 'returns 404' do
- subject
+ shared_examples 'counter is increased' do |counter|
+ context 'when the authenticated user has access to the project' do
+ let(:user) { project.owner }
- expect(response).to have_gitlab_http_status(:not_found)
- end
+ it 'increments the usage counter' do
+ expect do
+ subject
+ end.to change { Gitlab::UsageDataCounters::WebIdeCounter.total_count(counter) }.by(1)
end
+ end
+ end
- context 'when the user has access to the project' do
- let(:user) { project.owner }
+ describe 'POST #web_ide_clientside_preview' do
+ subject { post :web_ide_clientside_preview, params: { namespace_id: project.namespace, project_id: project } }
- it 'increments the counter' do
- expect do
- subject
- end.to change { Gitlab::UsageDataCounters::WebIdeCounter.total_previews_count }.by(1)
- end
+ context 'when web ide clientside preview is enabled' do
+ before do
+ stub_application_setting(web_ide_clientside_preview_enabled: true)
end
+
+ it_behaves_like 'counter is not increased'
+ it_behaves_like 'counter is increased', 'WEB_IDE_PREVIEWS_COUNT'
end
context 'when web ide clientside preview is not enabled' do
@@ -61,4 +68,11 @@ describe Projects::UsagePingController do
end
end
end
+
+ describe 'POST #web_ide_pipelines_count' do
+ subject { post :web_ide_pipelines_count, params: { namespace_id: project.namespace, project_id: project } }
+
+ it_behaves_like 'counter is not increased'
+ it_behaves_like 'counter is increased', 'WEB_IDE_PIPELINES_COUNT'
+ end
end
diff --git a/spec/controllers/projects/wikis_controller_spec.rb b/spec/controllers/projects/wikis_controller_spec.rb
index 99d14298cd1..b4bbf76ce18 100644
--- a/spec/controllers/projects/wikis_controller_spec.rb
+++ b/spec/controllers/projects/wikis_controller_spec.rb
@@ -98,13 +98,12 @@ describe Projects::WikisController do
let(:id) { wiki_title }
it 'limits the retrieved pages for the sidebar' do
- expect(controller).to receive(:load_wiki).and_return(project_wiki)
- expect(project_wiki).to receive(:list_pages).with(limit: 15).and_call_original
-
subject
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:page).title).to eq(wiki_title)
+ expect(assigns(:sidebar_wiki_entries)).to contain_exactly(an_instance_of(WikiPage))
+ expect(assigns(:sidebar_limited)).to be(false)
end
context 'when page content encoding is invalid' do
@@ -200,7 +199,20 @@ describe Projects::WikisController do
subject
- expect(response).to redirect_to(project_wiki_path(project, project_wiki.list_pages.first))
+ expect(response).to redirect_to_wiki(project, project_wiki.list_pages.first)
+ end
+ end
+
+ context 'when the page has nil content' do
+ let(:page) { create(:wiki_page) }
+
+ it 'redirects to show' do
+ allow(page).to receive(:content).and_return(nil)
+ allow(controller).to receive(:find_page).and_return(page)
+
+ subject
+
+ expect(response).to redirect_to_wiki(project, page)
end
end
@@ -235,7 +247,7 @@ describe Projects::WikisController do
allow(controller).to receive(:valid_encoding?).and_return(false)
subject
- expect(response).to redirect_to(project_wiki_path(project, project_wiki.list_pages.first))
+ expect(response).to redirect_to_wiki(project, project_wiki.list_pages.first)
end
end
@@ -265,4 +277,8 @@ describe Projects::WikisController do
page = wiki.page(title: title, dir: dir)
project_wiki.delete_page(page, "test commit")
end
+
+ def redirect_to_wiki(project, page)
+ redirect_to(controller.project_wiki_path(project, page))
+ end
end
diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb
index fc3efc8e805..6c00dad8bb7 100644
--- a/spec/controllers/projects_controller_spec.rb
+++ b/spec/controllers/projects_controller_spec.rb
@@ -1020,6 +1020,32 @@ describe ProjectsController do
expect(json_response['body']).to include(expanded_path)
end
end
+
+ context 'when path and ref parameters are provided' do
+ let(:project_with_repo) { create(:project, :repository) }
+ let(:preview_markdown_params) do
+ {
+ namespace_id: project_with_repo.namespace,
+ id: project_with_repo,
+ text: "![](./logo-white.png)\n",
+ ref: 'other_branch',
+ path: 'files/images/README.md'
+ }
+ end
+
+ before do
+ project_with_repo.add_maintainer(user)
+ project_with_repo.repository.create_branch('other_branch')
+ end
+
+ it 'renders JSON body with image links expanded' do
+ expanded_path = "/#{project_with_repo.full_path}/-/raw/other_branch/files/images/logo-white.png"
+
+ post :preview_markdown, params: preview_markdown_params
+
+ expect(json_response['body']).to include(expanded_path)
+ end
+ end
end
describe '#ensure_canonical_path' do
diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb
index 0b4ecb68cf7..01a9647a763 100644
--- a/spec/controllers/registrations_controller_spec.rb
+++ b/spec/controllers/registrations_controller_spec.rb
@@ -139,21 +139,11 @@ 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 dashboard when the reCAPTCHA is solved' do
post(:create, params: user_params)
expect(flash[:notice]).to eq(I18n.t('devise.registrations.signed_up'))
end
-
- it 'does not require reCAPTCHA if disabled by feature flag' do
- stub_feature_flags(registrations_recaptcha: false)
-
- post(:create, params: user_params)
-
- expect(controller).not_to receive(:verify_recaptcha)
- expect(flash[:alert]).to be_nil
- expect(flash[:notice]).to eq(I18n.t('devise.registrations.signed_up'))
- end
end
context 'when invisible captcha is enabled' do
@@ -294,8 +284,6 @@ describe RegistrationsController do
end
it "logs a 'User Created' message" do
- stub_feature_flags(registrations_recaptcha: false)
-
expect(Gitlab::AppLogger).to receive(:info).with(/\AUser Created: username=new_username email=new@user.com.+\z/).and_call_original
post(:create, params: user_params)
@@ -419,24 +407,34 @@ describe RegistrationsController do
describe '#welcome' do
subject { get :welcome }
- before do
- sign_in(create(:user))
- end
-
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))
+
expected_layout = Gitlab.ee? ? :checkout : :devise_experimental_separate_sign_up_flow
expect(subject).to render_template(expected_layout)
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
+ end
end
context 'signup_flow experiment disabled' do
before do
+ sign_in(create(:user))
stub_experiment_for_user(signup_flow: false)
end
diff --git a/spec/controllers/repositories/git_http_controller_spec.rb b/spec/controllers/repositories/git_http_controller_spec.rb
index 59455d90c25..1a2eee5d3a9 100644
--- a/spec/controllers/repositories/git_http_controller_spec.rb
+++ b/spec/controllers/repositories/git_http_controller_spec.rb
@@ -165,48 +165,11 @@ describe Repositories::GitHttpController do
end
end
- shared_examples 'snippet feature flag disabled behavior' do
- before do
- stub_feature_flags(version_snippets: false)
-
- request.headers.merge! auth_env(user.username, user.password, nil)
- end
-
- describe 'GET #info_refs' do
- let(:params) { container_params.merge(service: 'git-upload-pack') }
-
- it 'returns 403' do
- expect(controller).not_to receive(:access_check)
-
- get :info_refs, params: params
-
- expect(response).to have_gitlab_http_status(:forbidden)
- expect(response.body).to eq 'Snippet git access is disabled.'
- end
- end
-
- describe 'POST #git_upload_pack' do
- before do
- allow(controller).to receive(:authenticate_user).and_return(true)
- allow(controller).to receive(:verify_workhorse_api!).and_return(true)
- allow(controller).to receive(:access_check).and_return(nil)
- end
-
- it 'returns 403' do
- expect(controller).not_to receive(:access_check)
-
- post :git_upload_pack, params: params
-
- expect(response).to have_gitlab_http_status(:forbidden)
- expect(response.body).to eq 'Snippet git access is disabled.'
- end
- end
- end
-
context 'when repository container is a project' do
it_behaves_like 'info_refs behavior' do
let(:user) { project.owner }
end
+
it_behaves_like 'git_upload_pack behavior', true
it_behaves_like 'access checker class' do
let(:expected_class) { Gitlab::GitAccess }
@@ -221,14 +184,12 @@ describe Repositories::GitHttpController do
it_behaves_like 'info_refs behavior' do
let(:user) { personal_snippet.author }
end
+
it_behaves_like 'git_upload_pack behavior', false
it_behaves_like 'access checker class' do
let(:expected_class) { Gitlab::GitAccessSnippet }
let(:expected_object) { personal_snippet }
end
- it_behaves_like 'snippet feature flag disabled behavior' do
- let(:user) { personal_snippet.author }
- end
end
context 'when repository container is a project snippet' do
@@ -238,13 +199,11 @@ describe Repositories::GitHttpController do
it_behaves_like 'info_refs behavior' do
let(:user) { project_snippet.author }
end
+
it_behaves_like 'git_upload_pack behavior', false
it_behaves_like 'access checker class' do
let(:expected_class) { Gitlab::GitAccessSnippet }
let(:expected_object) { project_snippet }
end
- it_behaves_like 'snippet feature flag disabled behavior' do
- let(:user) { project_snippet.author }
- end
end
end
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index 1fe313452fe..79ffa297da3 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -84,7 +84,7 @@ describe SearchController do
with_them do
it do
project_wiki = create(:project_wiki, project: project, user: user)
- create(:wiki_page, wiki: project_wiki, attrs: { title: 'merge', content: 'merge' })
+ create(:wiki_page, wiki: project_wiki, title: 'merge', content: 'merge')
expect(subject).to render_template("search/results/#{partial}")
end
@@ -140,14 +140,6 @@ describe SearchController do
end
end
- context 'snippet search' do
- it 'forces title search' do
- get :show, params: { scope: 'snippet_blobs', snippets: 'true', search: 'foo' }
-
- expect(assigns[:scope]).to eq('snippet_titles')
- end
- end
-
it 'finds issue comments' do
project = create(:project, :public)
note = create(:note_on_issue, project: project)
diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb
index af2e452c0ca..a65698a5b56 100644
--- a/spec/controllers/sessions_controller_spec.rb
+++ b/spec/controllers/sessions_controller_spec.rb
@@ -41,10 +41,10 @@ describe SessionsController do
stub_ldap_setting(enabled: true)
end
- it 'assigns ldap_servers' do
+ it 'ldap_servers available in helper' do
get(:new)
- expect(assigns[:ldap_servers].first.to_h).to include('label' => 'ldap', 'provider_name' => 'ldapmain')
+ expect(subject.ldap_servers.first.to_h).to include('label' => 'ldap', 'provider_name' => 'ldapmain')
end
context 'with sign_in disabled' do
@@ -52,10 +52,10 @@ describe SessionsController do
stub_ldap_setting(prevent_ldap_sign_in: true)
end
- it 'assigns no ldap_servers' do
+ it 'no ldap_servers available in helper' do
get(:new)
- expect(assigns[:ldap_servers]).to eq []
+ expect(subject.ldap_servers).to eq []
end
end
end
@@ -99,6 +99,11 @@ describe SessionsController do
set_devise_mapping(context: @request)
end
+ it_behaves_like 'known sign in' do
+ let(:user) { create(:user) }
+ let(:post_action) { post(:create, params: { user: { login: user.username, password: user.password } }) }
+ end
+
context 'when using standard authentications' do
context 'invalid password' do
it 'does not authenticate user' do
diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb
index 05c48fb190c..046ee40cec2 100644
--- a/spec/controllers/snippets_controller_spec.rb
+++ b/spec/controllers/snippets_controller_spec.rb
@@ -86,20 +86,6 @@ describe SnippetsController do
expect(assigns(:blob)).to eq(personal_snippet.blobs.first)
end
-
- context 'when feature flag version_snippets is disabled' do
- before do
- stub_feature_flags(version_snippets: false)
- end
-
- it 'returns the snippet database content' do
- subject
-
- blob = assigns(:blob)
-
- expect(blob.data).to eq(personal_snippet.content)
- end
- end
end
context 'when the personal snippet is private' do
@@ -257,39 +243,13 @@ describe SnippetsController do
end
end
- context 'when the snippet description contains a file' do
- include FileMoverHelpers
-
- let(:picture_secret) { SecureRandom.hex }
- let(:text_secret) { SecureRandom.hex }
- let(:picture_file) { "/-/system/user/#{user.id}/#{picture_secret}/picture.jpg" }
- let(:text_file) { "/-/system/user/#{user.id}/#{text_secret}/text.txt" }
- let(:description) do
- "Description with picture: ![picture](/uploads#{picture_file}) and "\
- "text: [text.txt](/uploads#{text_file})"
- end
-
- before do
- allow(FileUtils).to receive(:mkdir_p)
- allow(FileUtils).to receive(:move)
- stub_file_mover(text_file)
- stub_file_mover(picture_file)
- end
-
- subject { create_snippet({ description: description }, { files: [picture_file, text_file] }) }
-
- it 'creates the snippet' do
- expect { subject }.to change { Snippet.count }.by(1)
- end
-
- it 'stores the snippet description correctly' do
- snippet = subject
+ context 'when the controller receives the files param' do
+ let(:files) { %w(foo bar) }
- expected_description = "Description with picture: "\
- "![picture](/uploads/-/system/personal_snippet/#{snippet.id}/#{picture_secret}/picture.jpg) and "\
- "text: [text.txt](/uploads/-/system/personal_snippet/#{snippet.id}/#{text_secret}/text.txt)"
+ it 'passes the files param to the snippet create service' do
+ expect(Snippets::CreateService).to receive(:new).with(nil, user, hash_including(files: files)).and_call_original
- expect(snippet.description).to eq(expected_description)
+ create_snippet({ title: nil }, { files: files })
end
end
@@ -318,7 +278,7 @@ describe SnippetsController do
.to log_spam(title: 'Title', user: user, noteable_type: 'PersonalSnippet')
end
- it 'renders :new with recaptcha disabled' do
+ it 'renders :new with reCAPTCHA disabled' do
stub_application_setting(recaptcha_enabled: false)
create_snippet(visibility_level: Snippet::PUBLIC)
@@ -326,18 +286,18 @@ describe SnippetsController do
expect(response).to render_template(:new)
end
- context 'recaptcha enabled' do
+ context 'reCAPTCHA enabled' do
before do
stub_application_setting(recaptcha_enabled: true)
end
- it 'renders :verify with recaptcha enabled' do
+ it 'renders :verify' do
create_snippet(visibility_level: Snippet::PUBLIC)
expect(response).to render_template(:verify)
end
- it 'renders snippet page when recaptcha verified' do
+ it 'renders snippet page' do
spammy_title = 'Whatever'
spam_logs = create_list(:spam_log, 2, user: user, title: spammy_title)
@@ -403,7 +363,7 @@ describe SnippetsController do
.to log_spam(title: 'Foo', user: user, noteable_type: 'PersonalSnippet')
end
- it 'renders :edit with recaptcha disabled' do
+ it 'renders :edit with reCAPTCHA disabled' do
stub_application_setting(recaptcha_enabled: false)
update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC)
@@ -411,18 +371,18 @@ describe SnippetsController do
expect(response).to render_template(:edit)
end
- context 'recaptcha enabled' do
+ context 'reCAPTCHA enabled' do
before do
stub_application_setting(recaptcha_enabled: true)
end
- it 'renders :verify with recaptcha enabled' do
+ it 'renders :verify' do
update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC)
expect(response).to render_template(:verify)
end
- it 'renders snippet page when recaptcha verified' do
+ it 'renders snippet page when reCAPTCHA verified' do
spammy_title = 'Whatever'
spam_logs = create_list(:spam_log, 2, user: user, title: spammy_title)
@@ -446,7 +406,7 @@ describe SnippetsController do
.to log_spam(title: 'Foo', user: user, noteable_type: 'PersonalSnippet')
end
- it 'renders :edit with recaptcha disabled' do
+ it 'renders :edit with reCAPTCHA disabled' do
stub_application_setting(recaptcha_enabled: false)
update_snippet(title: 'Foo')
@@ -459,13 +419,13 @@ describe SnippetsController do
stub_application_setting(recaptcha_enabled: true)
end
- it 'renders :verify with recaptcha enabled' do
+ it 'renders :verify' do
update_snippet(title: 'Foo')
expect(response).to render_template(:verify)
end
- it 'renders snippet page when recaptcha verified' do
+ it 'renders snippet page when reCAPTCHA verified' do
spammy_title = 'Whatever'
spam_logs = create_list(:spam_log, 2, user: user, title: spammy_title)
@@ -572,24 +532,6 @@ describe SnippetsController do
expect(response.cache_control[:public]).to eq snippet.public?
end
- context 'when feature flag version_snippets is disabled' do
- before do
- stub_feature_flags(version_snippets: false)
- end
-
- it_behaves_like '200 status'
- it_behaves_like 'CRLF line ending'
-
- it 'returns snippet database content' do
- subject
-
- expect(response.body).to eq snippet.content
- expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8')
- end
-
- it_behaves_like 'content disposition headers'
- end
-
context 'when snippet repository is empty' do
before do
allow_any_instance_of(Repository).to receive(:empty?).and_return(true)
diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb
index 2e22e67f4e2..eac9eb7aa47 100644
--- a/spec/db/schema_spec.rb
+++ b/spec/db/schema_spec.rb
@@ -14,9 +14,9 @@ describe 'Database schema' do
IGNORED_FK_COLUMNS = {
abuse_reports: %w[reporter_id user_id],
application_settings: %w[performance_bar_allowed_group_id slack_app_id snowplow_app_id eks_account_id eks_access_key_id],
- approvers: %w[target_id user_id],
approvals: %w[user_id],
approver_groups: %w[target_id],
+ approvers: %w[target_id user_id],
audit_events: %w[author_id entity_id],
award_emoji: %w[awardable_id user_id],
aws_roles: %w[role_external_id],
@@ -29,12 +29,13 @@ describe 'Database schema' do
ci_trigger_requests: %w[commit_id],
cluster_providers_aws: %w[security_group_id vpc_id access_key_id],
cluster_providers_gcp: %w[gcp_project_id operation_id],
+ commit_user_mentions: %w[commit_id],
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],
- events: %w[target_id],
epics: %w[updated_by_id last_edited_by_id state_id],
+ events: %w[target_id],
forked_project_links: %w[forked_from_project_id],
geo_event_log: %w[hashed_storage_attachments_event_id],
geo_job_artifact_deleted_events: %w[job_artifact_id],
@@ -44,14 +45,14 @@ describe 'Database schema' do
geo_repository_deleted_events: %w[project_id],
geo_upload_deleted_events: %w[upload_id model_id],
gitlab_subscription_histories: %w[gitlab_subscription_id hosted_plan_id namespace_id],
- import_failures: %w[project_id],
identities: %w[user_id],
+ import_failures: %w[project_id],
issues: %w[last_edited_by_id state_id],
jira_tracker_data: %w[jira_issue_transition_id],
keys: %w[user_id],
label_links: %w[target_id],
- lfs_objects_projects: %w[lfs_object_id project_id],
ldap_group_links: %w[group_id],
+ lfs_objects_projects: %w[lfs_object_id project_id],
members: %w[source_id created_by_id],
merge_requests: %w[last_edited_by_id state_id],
namespaces: %w[owner_id parent_id],
@@ -63,15 +64,16 @@ describe 'Database schema' do
open_project_tracker_data: %w[closed_status_id],
project_group_links: %w[group_id],
project_statistics: %w[namespace_id],
- projects: %w[creator_id namespace_id ci_id mirror_user_id],
+ projects: %w[creator_id ci_id mirror_user_id],
redirect_routes: %w[source_id],
repository_languages: %w[programming_language_id],
routes: %w[source_id],
sent_notifications: %w[project_id noteable_id recipient_id commit_id in_reply_to_discussion_id],
+ slack_integrations: %w[team_id user_id],
snippets: %w[author_id],
spam_logs: %w[user_id],
subscriptions: %w[user_id subscribable_id],
- slack_integrations: %w[team_id user_id],
+ suggestions: %w[commit_id],
taggings: %w[tag_id taggable_id tagger_id],
timelogs: %w[user_id],
todos: %w[target_id commit_id],
@@ -81,9 +83,7 @@ describe 'Database schema' do
users_star_projects: %w[user_id],
vulnerability_identifiers: %w[external_id],
vulnerability_scanners: %w[external_id],
- web_hooks: %w[service_id group_id],
- suggestions: %w[commit_id],
- commit_user_mentions: %w[commit_id]
+ web_hooks: %w[service_id group_id]
}.with_indifferent_access.freeze
context 'for table' do
diff --git a/spec/factories/alert_management/alerts.rb b/spec/factories/alert_management/alerts.rb
new file mode 100644
index 00000000000..01f40a7a465
--- /dev/null
+++ b/spec/factories/alert_management/alerts.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+require 'ffaker'
+
+FactoryBot.define do
+ factory :alert_management_alert, class: 'AlertManagement::Alert' do
+ triggered
+ project
+ title { FFaker::Lorem.sentence }
+ started_at { Time.current }
+
+ trait :with_issue do
+ issue
+ end
+
+ trait :with_fingerprint do
+ fingerprint { SecureRandom.hex }
+ end
+
+ trait :with_service do
+ service { FFaker::Product.product_name }
+ end
+
+ trait :with_monitoring_tool do
+ monitoring_tool { FFaker::AWS.product_description }
+ end
+
+ trait :with_description do
+ description { FFaker::Lorem.sentence }
+ end
+
+ trait :with_host do
+ hosts { [FFaker::Internet.ip_v4_address] }
+ end
+
+ trait :with_ended_at do
+ ended_at { Time.current }
+ end
+
+ trait :without_ended_at do
+ ended_at { nil }
+ end
+
+ trait :triggered do
+ status { AlertManagement::Alert::STATUSES[:triggered] }
+ without_ended_at
+ end
+
+ trait :acknowledged do
+ status { AlertManagement::Alert::STATUSES[:acknowledged] }
+ without_ended_at
+ end
+
+ trait :resolved do
+ status { AlertManagement::Alert::STATUSES[:resolved] }
+ with_ended_at
+ end
+
+ trait :ignored do
+ status { AlertManagement::Alert::STATUSES[:ignored] }
+ without_ended_at
+ end
+
+ trait :low_severity do
+ severity { 'low' }
+ end
+
+ trait :prometheus do
+ monitoring_tool { Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus] }
+ end
+
+ trait :all_fields do
+ with_issue
+ with_fingerprint
+ with_service
+ with_monitoring_tool
+ with_host
+ with_description
+ low_severity
+ end
+ end
+end
diff --git a/spec/factories/appearances.rb b/spec/factories/appearances.rb
index e2922662ea4..8101cd8d8bf 100644
--- a/spec/factories/appearances.rb
+++ b/spec/factories/appearances.rb
@@ -7,6 +7,7 @@ FactoryBot.define do
title { "GitLab Community Edition" }
description { "Open source software to collaborate on code" }
new_project_guidelines { "Custom project guidelines" }
+ profile_image_guidelines { "Custom profile image guidelines" }
end
trait :with_logo do
diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb
index fb3c163dff1..26786aab12c 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -314,12 +314,30 @@ FactoryBot.define do
end
end
+ trait :broken_test_reports do
+ after(:build) do |build|
+ build.job_artifacts << create(:ci_job_artifact, :junit_with_corrupted_data, job: build)
+ end
+ end
+
+ trait :accessibility_reports do
+ after(:build) do |build|
+ build.job_artifacts << create(:ci_job_artifact, :accessibility, job: build)
+ end
+ end
+
trait :coverage_reports do
after(:build) do |build|
build.job_artifacts << create(:ci_job_artifact, :cobertura, job: build)
end
end
+ trait :terraform_reports do
+ after(:build) do |build|
+ build.job_artifacts << create(:ci_job_artifact, :terraform, job: build)
+ end
+ end
+
trait :expired do
artifacts_expire_at { 1.minute.ago }
end
diff --git a/spec/factories/ci/daily_build_group_report_results.rb b/spec/factories/ci/daily_build_group_report_results.rb
new file mode 100644
index 00000000000..8653316b51a
--- /dev/null
+++ b/spec/factories/ci/daily_build_group_report_results.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :ci_daily_build_group_report_result, class: 'Ci::DailyBuildGroupReportResult' do
+ ref_path { Gitlab::Git::BRANCH_REF_PREFIX + 'master' }
+ date { Time.zone.now.to_date }
+ project
+ last_pipeline factory: :ci_pipeline
+ group_name { 'rspec' }
+ data do
+ { 'coverage' => 77.0 }
+ end
+ end
+end
diff --git a/spec/factories/ci/daily_report_results.rb b/spec/factories/ci/daily_report_results.rb
deleted file mode 100644
index e2255e8a134..00000000000
--- a/spec/factories/ci/daily_report_results.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :ci_daily_report_result, class: 'Ci::DailyReportResult' do
- ref_path { Gitlab::Git::BRANCH_REF_PREFIX + 'master' }
- date { Time.zone.now.to_date }
- project
- last_pipeline factory: :ci_pipeline
- param_type { Ci::DailyReportResult.param_types[:coverage] }
- title { 'rspec' }
- value { 77.0 }
- end
-end
diff --git a/spec/factories/ci/freeze_periods.rb b/spec/factories/ci/freeze_periods.rb
new file mode 100644
index 00000000000..de48c2076c8
--- /dev/null
+++ b/spec/factories/ci/freeze_periods.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :ci_freeze_period, class: 'Ci::FreezePeriod' do
+ project
+ freeze_start { '0 23 * * 5' }
+ freeze_end { '0 7 * * 1' }
+ cron_timezone { 'UTC' }
+ end
+end
diff --git a/spec/factories/ci/instance_variables.rb b/spec/factories/ci/instance_variables.rb
new file mode 100644
index 00000000000..5a3551d3561
--- /dev/null
+++ b/spec/factories/ci/instance_variables.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :ci_instance_variable, class: 'Ci::InstanceVariable' do
+ sequence(:key) { |n| "VARIABLE_#{n}" }
+ value { 'VARIABLE_VALUE' }
+ masked { false }
+
+ trait(:protected) do
+ add_attribute(:protected) { true }
+ end
+ end
+end
diff --git a/spec/factories/ci/job_artifacts.rb b/spec/factories/ci/job_artifacts.rb
index 82383cfa2b0..26c09795a0b 100644
--- a/spec/factories/ci/job_artifacts.rb
+++ b/spec/factories/ci/job_artifacts.rb
@@ -139,6 +139,36 @@ FactoryBot.define do
end
end
+ trait :accessibility do
+ file_type { :accessibility }
+ file_format { :raw }
+
+ after(:build) do |artifact, _evaluator|
+ artifact.file = fixture_file_upload(
+ Rails.root.join('spec/fixtures/accessibility/pa11y_with_errors.json'), 'application/json')
+ end
+ end
+
+ trait :accessibility_with_invalid_url do
+ file_type { :accessibility }
+ file_format { :raw }
+
+ after(:build) do |artifact, _evaluator|
+ artifact.file = fixture_file_upload(
+ Rails.root.join('spec/fixtures/accessibility/pa11y_with_invalid_url.json'), 'application/json')
+ end
+ end
+
+ trait :accessibility_without_errors do
+ file_type { :accessibility }
+ file_format { :raw }
+
+ after(:build) do |artifact, _evaluator|
+ artifact.file = fixture_file_upload(
+ Rails.root.join('spec/fixtures/accessibility/pa11y_without_errors.json'), 'application/json')
+ end
+ end
+
trait :cobertura do
file_type { :cobertura }
file_format { :gzip }
@@ -149,6 +179,26 @@ FactoryBot.define do
end
end
+ trait :terraform do
+ file_type { :terraform }
+ file_format { :raw }
+
+ after(:build) do |artifact, evaluator|
+ artifact.file = fixture_file_upload(
+ Rails.root.join('spec/fixtures/terraform/tfplan.json'), 'application/json')
+ end
+ end
+
+ trait :terraform_with_corrupted_data do
+ file_type { :terraform }
+ file_format { :raw }
+
+ after(:build) do |artifact, evaluator|
+ artifact.file = fixture_file_upload(
+ Rails.root.join('spec/fixtures/terraform/tfplan_with_corrupted_data.json'), 'application/json')
+ end
+ end
+
trait :coverage_gocov_xml do
file_type { :cobertura }
file_format { :gzip }
@@ -181,11 +231,14 @@ FactoryBot.define do
trait :lsif do
file_type { :lsif }
- file_format { :gzip }
+ file_format { :zip }
+
+ transient do
+ file_path { Rails.root.join('spec/fixtures/lsif.json.gz') }
+ end
after(:build) do |artifact, evaluator|
- artifact.file = fixture_file_upload(
- Rails.root.join('spec/fixtures/lsif.json.gz'), 'application/x-gzip')
+ artifact.file = fixture_file_upload(evaluator.file_path, 'application/x-gzip')
end
end
@@ -199,6 +252,21 @@ FactoryBot.define do
end
end
+ trait :cluster_applications do
+ file_type { :cluster_applications }
+ file_format { :gzip }
+
+ transient do
+ file do
+ fixture_file_upload(Rails.root.join('spec/fixtures/helm/helm_list_v2_prometheus_missing.json.gz'), 'application/x-gzip')
+ end
+ end
+
+ after(:build) do |artifact, evaluator|
+ artifact.file = evaluator.file
+ end
+ end
+
trait :correct_checksum do
after(:build) do |artifact, evaluator|
artifact.file_sha256 = Digest::SHA256.file(artifact.file.path).hexdigest
diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb
index 257dd3337ba..0b3653a01ed 100644
--- a/spec/factories/ci/pipelines.rb
+++ b/spec/factories/ci/pipelines.rb
@@ -75,6 +75,22 @@ FactoryBot.define do
end
end
+ trait :with_broken_test_reports do
+ status { :success }
+
+ after(:build) do |pipeline, _evaluator|
+ pipeline.builds << build(:ci_build, :broken_test_reports, pipeline: pipeline, project: pipeline.project)
+ end
+ end
+
+ trait :with_accessibility_reports do
+ status { :success }
+
+ after(:build) do |pipeline, evaluator|
+ pipeline.builds << build(:ci_build, :accessibility_reports, pipeline: pipeline, project: pipeline.project)
+ end
+ end
+
trait :with_coverage_reports do
status { :success }
@@ -83,6 +99,14 @@ FactoryBot.define do
end
end
+ trait :with_terraform_reports do
+ status { :success }
+
+ after(:build) do |pipeline, evaluator|
+ pipeline.builds << build(:ci_build, :terraform_reports, pipeline: pipeline, project: pipeline.project)
+ end
+ end
+
trait :with_exposed_artifacts do
status { :success }
diff --git a/spec/factories/ci/test_case.rb b/spec/factories/ci/test_case.rb
index bb1508c0d75..0639aac566a 100644
--- a/spec/factories/ci/test_case.rb
+++ b/spec/factories/ci/test_case.rb
@@ -16,7 +16,7 @@ FactoryBot.define do
system_output { "Failure/Error: is_expected.to eq(300) expected: 300 got: -100" }
end
- trait :with_attachment do
+ trait :failed_with_attachment do
status { Gitlab::Ci::Reports::TestCase::STATUS_FAILED }
attachment { "some/path.png" }
end
diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb
index 728c83e01b4..c49c26f06e5 100644
--- a/spec/factories/clusters/applications/helm.rb
+++ b/spec/factories/clusters/applications/helm.rb
@@ -65,6 +65,10 @@ FactoryBot.define do
status_reason { 'something went wrong' }
end
+ trait :uninstalled do
+ status { 10 }
+ end
+
trait :timed_out do
installing
updated_at { ClusterWaitForAppInstallationWorker::TIMEOUT.ago }
@@ -77,6 +81,24 @@ FactoryBot.define do
trait :no_helm_installed do
cluster factory: %i(cluster provided_by_gcp)
end
+
+ trait :modsecurity_blocking do
+ modsecurity_enabled { true }
+ modsecurity_mode { :blocking }
+ end
+
+ trait :modsecurity_logging do
+ modsecurity_enabled { true }
+ modsecurity_mode { :logging }
+ end
+
+ trait :modsecurity_disabled do
+ modsecurity_enabled { false }
+ end
+
+ trait :modsecurity_not_installed do
+ modsecurity_enabled { nil }
+ end
end
factory :clusters_applications_cert_manager, class: 'Clusters::Applications::CertManager' do
@@ -142,6 +164,8 @@ FactoryBot.define do
factory :clusters_applications_fluentd, class: 'Clusters::Applications::Fluentd' do
host { 'example.com' }
+ waf_log_enabled { true }
+ cilium_log_enabled { true }
cluster factory: %i(cluster with_installed_helm provided_by_gcp)
trait :no_helm_installed do
diff --git a/spec/factories/deploy_tokens.rb b/spec/factories/deploy_tokens.rb
index 657915f9976..d4127f78ebf 100644
--- a/spec/factories/deploy_tokens.rb
+++ b/spec/factories/deploy_tokens.rb
@@ -8,6 +8,8 @@ FactoryBot.define do
read_repository { true }
read_registry { true }
write_registry { false }
+ read_package_registry { false }
+ write_package_registry { false }
revoked { false }
expires_at { 5.days.from_now }
deploy_token_type { DeployToken.deploy_token_types[:project_type] }
@@ -31,5 +33,11 @@ FactoryBot.define do
trait :project do
deploy_token_type { DeployToken.deploy_token_types[:project_type] }
end
+
+ trait :all_scopes do
+ write_registry { true}
+ read_package_registry { true }
+ write_package_registry { true }
+ end
end
end
diff --git a/spec/factories/design_management/actions.rb b/spec/factories/design_management/actions.rb
new file mode 100644
index 00000000000..e2561f98f52
--- /dev/null
+++ b/spec/factories/design_management/actions.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :design_action, class: 'DesignManagement::Action' do
+ design
+ association :version, factory: :design_version
+ event { :creation }
+
+ trait :with_image_v432x230 do
+ image_v432x230 { fixture_file_upload('spec/fixtures/dk.png') }
+ end
+ end
+end
diff --git a/spec/factories/design_management/design_at_version.rb b/spec/factories/design_management/design_at_version.rb
new file mode 100644
index 00000000000..b73df71595c
--- /dev/null
+++ b/spec/factories/design_management/design_at_version.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :design_at_version, class: 'DesignManagement::DesignAtVersion' do
+ skip_create # This is not an Active::Record model.
+
+ design { nil }
+
+ version { nil }
+
+ transient do
+ issue { design&.issue || version&.issue || create(:issue) }
+ end
+
+ initialize_with do
+ attrs = attributes.dup
+ attrs[:design] ||= create(:design, issue: issue)
+ attrs[:version] ||= create(:design_version, issue: issue)
+
+ new(attrs)
+ end
+ end
+end
diff --git a/spec/factories/design_management/designs.rb b/spec/factories/design_management/designs.rb
new file mode 100644
index 00000000000..59d4cc56f95
--- /dev/null
+++ b/spec/factories/design_management/designs.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :design, class: 'DesignManagement::Design' do
+ issue { create(:issue) }
+ project { issue&.project || create(:project) }
+ sequence(:filename) { |n| "homescreen-#{n}.jpg" }
+
+ transient do
+ author { issue.author }
+ end
+
+ trait :importing do
+ issue { nil }
+
+ importing { true }
+ imported { false }
+ end
+
+ trait :imported do
+ importing { false }
+ imported { true }
+ end
+
+ create_versions = ->(design, evaluator, commit_version) do
+ unless evaluator.versions_count.zero?
+ project = design.project
+ issue = design.issue
+ repository = project.design_repository
+ repository.create_if_not_exists
+ dv_table_name = DesignManagement::Action.table_name
+ updates = [0, evaluator.versions_count - (evaluator.deleted ? 2 : 1)].max
+
+ run_action = ->(action) do
+ sha = commit_version[action]
+ version = DesignManagement::Version.new(sha: sha, issue: issue, author: evaluator.author)
+ version.save(validate: false) # We need it to have an ID, validate later
+ Gitlab::Database.bulk_insert(dv_table_name, [action.row_attrs(version)])
+ end
+
+ # always a creation
+ run_action[DesignManagement::DesignAction.new(design, :create, evaluator.file)]
+
+ # 0 or more updates
+ updates.times do
+ run_action[DesignManagement::DesignAction.new(design, :update, evaluator.file)]
+ end
+
+ # and maybe a deletion
+ run_action[DesignManagement::DesignAction.new(design, :delete)] if evaluator.deleted
+ end
+
+ design.clear_version_cache
+ end
+
+ # Use this trait to build designs that are backed by Git LFS, committed
+ # to the repository, and with an LfsObject correctly created for it.
+ trait :with_lfs_file do
+ with_file
+
+ transient do
+ raw_file { fixture_file_upload('spec/fixtures/dk.png', 'image/png') }
+ lfs_pointer { Gitlab::Git::LfsPointerFile.new(SecureRandom.random_bytes) }
+ file { lfs_pointer.pointer }
+ end
+
+ after :create do |design, evaluator|
+ lfs_object = create(:lfs_object, file: evaluator.raw_file, oid: evaluator.lfs_pointer.sha256, size: evaluator.lfs_pointer.size)
+ create(:lfs_objects_project, project: design.project, lfs_object: lfs_object, repository_type: :design)
+ end
+ end
+
+ # Use this trait if you want versions in a particular history, but don't
+ # want to pay for gitlay calls.
+ trait :with_versions do
+ transient do
+ deleted { false }
+ versions_count { 1 }
+ sequence(:file) { |n| "some-file-content-#{n}" }
+ end
+
+ after :create do |design, evaluator|
+ counter = (1..).lazy
+
+ # Just produce a SHA by hashing the action and a monotonic counter
+ commit_version = ->(action) do
+ Digest::SHA1.hexdigest("#{action.gitaly_action}.#{counter.next}")
+ end
+
+ create_versions[design, evaluator, commit_version]
+ end
+ end
+
+ # Use this trait to build designs that have commits in the repository
+ # and files that can be retrieved.
+ trait :with_file do
+ transient do
+ deleted { false }
+ versions_count { 1 }
+ file { File.join(Rails.root, 'spec/fixtures/dk.png') }
+ end
+
+ after :create do |design, evaluator|
+ project = design.project
+ repository = project.design_repository
+
+ commit_version = ->(action) do
+ repository.multi_action(
+ evaluator.author,
+ branch_name: 'master',
+ message: "#{action.action} for #{design.filename}",
+ actions: [action.gitaly_action]
+ )
+ end
+
+ create_versions[design, evaluator, commit_version]
+ end
+ end
+
+ trait :with_smaller_image_versions do
+ with_lfs_file
+
+ after :create do |design|
+ design.versions.each { |v| DesignManagement::GenerateImageVersionsService.new(v).execute }
+ end
+ end
+ end
+end
diff --git a/spec/factories/design_management/versions.rb b/spec/factories/design_management/versions.rb
new file mode 100644
index 00000000000..e6d17ba691c
--- /dev/null
+++ b/spec/factories/design_management/versions.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :design_version, class: 'DesignManagement::Version' do
+ sha
+ issue { designs.first&.issue || create(:issue) }
+ author { issue&.author || create(:user) }
+
+ transient do
+ designs_count { 1 }
+ created_designs { [] }
+ modified_designs { [] }
+ deleted_designs { [] }
+ end
+
+ # Warning: this will intentionally result in an invalid version!
+ trait :empty do
+ designs_count { 0 }
+ end
+
+ trait :importing do
+ issue { nil }
+
+ designs_count { 0 }
+ importing { true }
+ imported { false }
+ end
+
+ trait :imported do
+ importing { false }
+ imported { true }
+ end
+
+ after(:build) do |version, evaluator|
+ # By default all designs are created_designs, so just add them.
+ specific_designs = [].concat(
+ evaluator.created_designs,
+ evaluator.modified_designs,
+ evaluator.deleted_designs
+ )
+ version.designs += specific_designs
+
+ unless evaluator.designs_count.zero? || version.designs.present?
+ version.designs << create(:design, issue: version.issue)
+ end
+ end
+
+ after :create do |version, evaluator|
+ # FactoryBot does not like methods, so we use lambdas instead
+ events = DesignManagement::Action.events
+
+ version.actions
+ .where(design_id: evaluator.modified_designs.map(&:id))
+ .update_all(event: events[:modification])
+
+ version.actions
+ .where(design_id: evaluator.deleted_designs.map(&:id))
+ .update_all(event: events[:deletion])
+
+ version.designs.reload
+ # Ensure version.issue == design.issue for all version.designs
+ version.designs.update_all(issue_id: version.issue_id)
+
+ needed = evaluator.designs_count
+ have = version.designs.size
+
+ create_list(:design, [0, needed - have].max, issue: version.issue).each do |d|
+ version.designs << d
+ end
+
+ version.actions.reset
+ end
+
+ # Use this trait to build versions with designs that are backed by Git LFS, committed
+ # to the repository, and with an LfsObject correctly created for it.
+ trait :with_lfs_file do
+ committed
+
+ transient do
+ raw_file { fixture_file_upload('spec/fixtures/dk.png', 'image/png') }
+ lfs_pointer { Gitlab::Git::LfsPointerFile.new(SecureRandom.random_bytes) }
+ file { lfs_pointer.pointer }
+ end
+
+ after :create do |version, evaluator|
+ lfs_object = create(:lfs_object, file: evaluator.raw_file, oid: evaluator.lfs_pointer.sha256, size: evaluator.lfs_pointer.size)
+ create(:lfs_objects_project, project: version.project, lfs_object: lfs_object, repository_type: :design)
+ end
+ end
+
+ # This trait is for versions that must be present in the git repository.
+ trait :committed do
+ transient do
+ file { File.join(Rails.root, 'spec/fixtures/dk.png') }
+ end
+
+ after :create do |version, evaluator|
+ project = version.issue.project
+ repository = project.design_repository
+ repository.create_if_not_exists
+
+ designs = version.designs_by_event
+ base_change = { content: evaluator.file }
+
+ actions = %w[modification deletion].flat_map { |k| designs.fetch(k, []) }.map do |design|
+ base_change.merge(action: :create, file_path: design.full_path)
+ end
+
+ if actions.present?
+ repository.multi_action(
+ evaluator.author,
+ branch_name: 'master',
+ message: "created #{actions.size} files",
+ actions: actions
+ )
+ end
+
+ mapping = {
+ 'creation' => :create,
+ 'modification' => :update,
+ 'deletion' => :delete
+ }
+
+ version_actions = designs.flat_map do |(event, designs)|
+ base = event == 'deletion' ? {} : base_change
+ designs.map do |design|
+ base.merge(action: mapping[event], file_path: design.full_path)
+ end
+ end
+
+ sha = repository.multi_action(
+ evaluator.author,
+ branch_name: 'master',
+ message: "edited #{version_actions.size} files",
+ actions: version_actions
+ )
+
+ version.update(sha: sha)
+ end
+ end
+ end
+end
diff --git a/spec/factories/events.rb b/spec/factories/events.rb
index 5b456bb58ff..ed6cb3505f4 100644
--- a/spec/factories/events.rb
+++ b/spec/factories/events.rb
@@ -25,13 +25,23 @@ FactoryBot.define do
factory :wiki_page_event do
action { Event::CREATED }
- project { @overrides[:wiki_page]&.project || create(:project, :wiki_repo) }
+ project { @overrides[:wiki_page]&.container || create(:project, :wiki_repo) }
target { create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
transient do
- wiki_page { create(:wiki_page, project: project) }
+ wiki_page { create(:wiki_page, container: project) }
end
end
+
+ trait :for_design do
+ transient do
+ design { create(:design, issue: create(:issue, project: project)) }
+ note { create(:note, author: author, project: project, noteable: design) }
+ end
+
+ action { Event::COMMENTED }
+ target { note }
+ end
end
factory :push_event, class: 'PushEvent' do
diff --git a/spec/factories/git_wiki_commit_details.rb b/spec/factories/git_wiki_commit_details.rb
new file mode 100644
index 00000000000..b35f102fd4d
--- /dev/null
+++ b/spec/factories/git_wiki_commit_details.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :git_wiki_commit_details, class: 'Gitlab::Git::Wiki::CommitDetails' do
+ skip_create
+
+ transient do
+ author { create(:user) }
+ end
+
+ sequence(:message) { |n| "Commit message #{n}" }
+
+ initialize_with { new(author.id, author.username, author.name, author.email, message) }
+ end
+end
diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb
index 4b6c1756d1e..d51c437f83a 100644
--- a/spec/factories/groups.rb
+++ b/spec/factories/groups.rb
@@ -6,7 +6,7 @@ FactoryBot.define do
path { name.downcase.gsub(/\s/, '_') }
type { 'Group' }
owner { nil }
- project_creation_level { ::Gitlab::Access::MAINTAINER_PROJECT_ACCESS}
+ project_creation_level { ::Gitlab::Access::MAINTAINER_PROJECT_ACCESS }
after(:create) do |group|
if group.owner
@@ -17,15 +17,15 @@ FactoryBot.define do
end
trait :public do
- visibility_level { Gitlab::VisibilityLevel::PUBLIC}
+ visibility_level { Gitlab::VisibilityLevel::PUBLIC }
end
trait :internal do
- visibility_level {Gitlab::VisibilityLevel::INTERNAL}
+ visibility_level {Gitlab::VisibilityLevel::INTERNAL }
end
trait :private do
- visibility_level { Gitlab::VisibilityLevel::PRIVATE}
+ visibility_level { Gitlab::VisibilityLevel::PRIVATE }
end
trait :with_avatar do
@@ -49,7 +49,7 @@ FactoryBot.define do
end
trait :owner_subgroup_creation_only do
- subgroup_creation_level { ::Gitlab::Access::OWNER_SUBGROUP_ACCESS}
+ subgroup_creation_level { ::Gitlab::Access::OWNER_SUBGROUP_ACCESS }
end
end
end
diff --git a/spec/factories/identities.rb b/spec/factories/identities.rb
index a2615ce30c3..fda4bfa589b 100644
--- a/spec/factories/identities.rb
+++ b/spec/factories/identities.rb
@@ -3,6 +3,6 @@
FactoryBot.define do
factory :identity do
provider { 'ldapmain' }
- extern_uid { 'my-ldap-id' }
+ sequence(:extern_uid) { |n| "my-ldap-id-#{n}" }
end
end
diff --git a/spec/factories/iterations.rb b/spec/factories/iterations.rb
new file mode 100644
index 00000000000..f6be1d9d752
--- /dev/null
+++ b/spec/factories/iterations.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ sequence(:sequential_date) do |n|
+ n.days.from_now
+ end
+
+ factory :iteration do
+ title
+ start_date { generate(:sequential_date) }
+ due_date { generate(:sequential_date) }
+
+ transient do
+ project { nil }
+ group { nil }
+ project_id { nil }
+ group_id { nil }
+ resource_parent { nil }
+ end
+
+ trait :upcoming do
+ state_enum { Iteration::STATE_ENUM_MAP[:upcoming] }
+ end
+
+ trait :started do
+ state_enum { Iteration::STATE_ENUM_MAP[:started] }
+ end
+
+ trait :closed do
+ state_enum { Iteration::STATE_ENUM_MAP[:closed] }
+ end
+
+ trait(:skip_future_date_validation) do
+ after(:stub, :build) do |iteration|
+ iteration.skip_future_date_validation = true
+ end
+ end
+
+ after(:build, :stub) do |iteration, evaluator|
+ if evaluator.group
+ iteration.group = evaluator.group
+ elsif evaluator.group_id
+ iteration.group_id = evaluator.group_id
+ elsif evaluator.project
+ iteration.project = evaluator.project
+ elsif evaluator.project_id
+ iteration.project_id = evaluator.project_id
+ elsif evaluator.resource_parent
+ id = evaluator.resource_parent.id
+ evaluator.resource_parent.is_a?(Group) ? evaluator.group_id = id : evaluator.project_id = id
+ else
+ iteration.project = create(:project)
+ end
+ end
+
+ factory :upcoming_iteration, traits: [:upcoming]
+ factory :started_iteration, traits: [:started]
+ factory :closed_iteration, traits: [:closed]
+ end
+end
diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb
index abccd775c8a..b10c04a37f7 100644
--- a/spec/factories/merge_requests.rb
+++ b/spec/factories/merge_requests.rb
@@ -121,6 +121,18 @@ FactoryBot.define do
end
end
+ trait :with_accessibility_reports do
+ after(:build) do |merge_request|
+ merge_request.head_pipeline = build(
+ :ci_pipeline,
+ :success,
+ :with_accessibility_reports,
+ project: merge_request.source_project,
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha)
+ end
+ end
+
trait :with_coverage_reports do
after(:build) do |merge_request|
merge_request.head_pipeline = build(
@@ -133,6 +145,18 @@ FactoryBot.define do
end
end
+ trait :with_terraform_reports do
+ after(:build) do |merge_request|
+ merge_request.head_pipeline = build(
+ :ci_pipeline,
+ :success,
+ :with_terraform_reports,
+ project: merge_request.source_project,
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha)
+ end
+ end
+
trait :with_exposed_artifacts do
after(:build) do |merge_request|
merge_request.head_pipeline = build(
diff --git a/spec/factories/metrics/users_starred_dasboards.rb b/spec/factories/metrics/users_starred_dasboards.rb
new file mode 100644
index 00000000000..06fe7735e9a
--- /dev/null
+++ b/spec/factories/metrics/users_starred_dasboards.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :metrics_users_starred_dashboard, class: '::Metrics::UsersStarredDashboard' do
+ dashboard_path { "custom_dashboard.yml" }
+ user
+ project
+ end
+end
diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb
index fdd1a9a18b2..7c3ba122b5a 100644
--- a/spec/factories/notes.rb
+++ b/spec/factories/notes.rb
@@ -16,6 +16,7 @@ FactoryBot.define do
factory :note_on_merge_request, traits: [:on_merge_request]
factory :note_on_project_snippet, traits: [:on_project_snippet]
factory :note_on_personal_snippet, traits: [:on_personal_snippet]
+ factory :note_on_design, traits: [:on_design]
factory :system_note, traits: [:system]
factory :discussion_note, class: 'DiscussionNote'
@@ -107,6 +108,10 @@ FactoryBot.define do
end
end
+ factory :diff_note_on_design, parent: :note, traits: [:on_design], class: 'DiffNote' do
+ position { build(:image_diff_position, file: noteable.full_path, diff_refs: noteable.diff_refs) }
+ end
+
trait :on_commit do
association :project, :repository
noteable { nil }
@@ -136,6 +141,20 @@ FactoryBot.define do
project { nil }
end
+ trait :on_design do
+ transient do
+ issue { association(:issue, project: project) }
+ end
+ noteable { association(:design, :with_file, issue: issue) }
+
+ after(:build) do |note|
+ next if note.project == note.noteable.project
+
+ # note validations require consistency between these two objects
+ note.project = note.noteable.project
+ end
+ end
+
trait :system do
system { true }
end
diff --git a/spec/factories/plan_limits.rb b/spec/factories/plan_limits.rb
new file mode 100644
index 00000000000..4aea09618d0
--- /dev/null
+++ b/spec/factories/plan_limits.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :plan_limits do
+ plan
+
+ trait :default_plan do
+ plan factory: :default_plan
+ end
+ end
+end
diff --git a/spec/factories/plans.rb b/spec/factories/plans.rb
new file mode 100644
index 00000000000..81506edcf16
--- /dev/null
+++ b/spec/factories/plans.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :plan do
+ Plan.all_plans.each do |plan|
+ factory :"#{plan}_plan" do
+ name { plan }
+ title { name.titleize }
+ initialize_with { Plan.find_or_create_by(name: plan) }
+ end
+ end
+ end
+end
diff --git a/spec/factories/project_repository_storage_moves.rb b/spec/factories/project_repository_storage_moves.rb
new file mode 100644
index 00000000000..aa8576834eb
--- /dev/null
+++ b/spec/factories/project_repository_storage_moves.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :project_repository_storage_move, class: 'ProjectRepositoryStorageMove' do
+ project
+
+ source_storage_name { 'default' }
+ destination_storage_name { 'default' }
+
+ trait :scheduled do
+ state { ProjectRepositoryStorageMove.state_machines[:state].states[:scheduled].value }
+ end
+ end
+end
diff --git a/spec/factories/project_wikis.rb b/spec/factories/project_wikis.rb
deleted file mode 100644
index 401402614f4..00000000000
--- a/spec/factories/project_wikis.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :project_wiki do
- skip_create
-
- association :project, :wiki_repo
- user { project.creator }
- initialize_with { new(project, user) }
- end
-end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index 64321c9f319..45caa7a2b6a 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -215,6 +215,12 @@ FactoryBot.define do
end
end
+ trait :design_repo do
+ after(:create) do |project|
+ raise 'Failed to create design repository!' unless project.design_repository.create_if_not_exists
+ end
+ end
+
trait :remote_mirror do
transient do
remote_name { "remote_mirror_#{SecureRandom.hex}" }
diff --git a/spec/factories/remote_mirrors.rb b/spec/factories/remote_mirrors.rb
index 124c0510cab..aa0ace30d90 100644
--- a/spec/factories/remote_mirrors.rb
+++ b/spec/factories/remote_mirrors.rb
@@ -4,5 +4,10 @@ FactoryBot.define do
factory :remote_mirror, class: 'RemoteMirror' do
association :project, :repository
url { "http://foo:bar@test.com" }
+
+ trait :ssh do
+ url { 'ssh://git@test.com:foo/bar.git' }
+ auth_method { 'ssh_public_key' }
+ end
end
end
diff --git a/spec/factories/resource_state_event.rb b/spec/factories/resource_state_event.rb
new file mode 100644
index 00000000000..e3de462b797
--- /dev/null
+++ b/spec/factories/resource_state_event.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :resource_state_event do
+ issue { merge_request.nil? ? create(:issue) : nil }
+ merge_request { nil }
+ state { :opened }
+ user { issue&.author || merge_request&.author || create(:user) }
+ end
+end
diff --git a/spec/factories/sequences.rb b/spec/factories/sequences.rb
index cdc64a8502e..ca0804965df 100644
--- a/spec/factories/sequences.rb
+++ b/spec/factories/sequences.rb
@@ -12,4 +12,5 @@ FactoryBot.define do
sequence(:branch) { |n| "my-branch-#{n}" }
sequence(:past_time) { |n| 4.hours.ago + (2 * n).seconds }
sequence(:iid)
+ sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") }
end
diff --git a/spec/factories/services.rb b/spec/factories/services.rb
index c011a9e3bb4..b6696769da9 100644
--- a/spec/factories/services.rb
+++ b/spec/factories/services.rb
@@ -158,6 +158,13 @@ FactoryBot.define do
token { 'test_token' }
end
+ factory :slack_service do
+ project
+ active { true }
+ webhook { 'https://slack.service.url' }
+ type { 'SlackService' }
+ end
+
# this is for testing storing values inside properties, which is deprecated and will be removed in
# https://gitlab.com/gitlab-org/gitlab/issues/29404
trait :without_properties_callback do
diff --git a/spec/factories/uploads.rb b/spec/factories/uploads.rb
index a060cd7d6f8..b19af277cc3 100644
--- a/spec/factories/uploads.rb
+++ b/spec/factories/uploads.rb
@@ -65,5 +65,11 @@ FactoryBot.define do
model { create(:note) }
uploader { "AttachmentUploader" }
end
+
+ trait :design_action_image_v432x230_upload do
+ mount_point { :image_v432x230 }
+ model { create(:design_action) }
+ uploader { ::DesignManagement::DesignV432x230Uploader.name }
+ end
end
end
diff --git a/spec/factories/usage_data.rb b/spec/factories/usage_data.rb
index b633038b83b..8fe0018b5a6 100644
--- a/spec/factories/usage_data.rb
+++ b/spec/factories/usage_data.rb
@@ -12,6 +12,11 @@ FactoryBot.define do
create(:jira_service, :jira_cloud_service, project: projects[2])
create(:jira_service, :without_properties_callback, project: projects[3],
properties: { url: 'https://mysite.atlassian.net' })
+ jira_label = create(:label, project: projects[0])
+ create(:jira_import_state, :finished, project: projects[0], label: jira_label, failed_to_import_count: 2, imported_issues_count: 7, total_issue_count: 9)
+ create(:jira_import_state, :finished, project: projects[1], label: jira_label, imported_issues_count: 3, total_issue_count: 3)
+ create(:jira_import_state, :finished, project: projects[1], label: jira_label, imported_issues_count: 3)
+ create(:jira_import_state, :scheduled, project: projects[1], label: jira_label)
create(:prometheus_service, project: projects[1])
create(:service, project: projects[0], type: 'SlackSlashCommandsService', active: true)
create(:service, project: projects[1], type: 'SlackService', active: true)
@@ -23,11 +28,10 @@ FactoryBot.define do
create(:project_error_tracking_setting, project: projects[1], enabled: false)
create(:alerts_service, project: projects[0])
create(:alerts_service, :inactive, project: projects[1])
- create_list(:issue, 2, project: projects[0], author: User.alert_bot)
+ alert_bot_issues = create_list(:issue, 2, project: projects[0], author: User.alert_bot)
create_list(:issue, 2, project: projects[1], author: User.alert_bot)
- create_list(:issue, 4, project: projects[0])
- create(:prometheus_alert, project: projects[0])
- create(:prometheus_alert, project: projects[0])
+ issues = create_list(:issue, 4, project: projects[0])
+ create_list(:prometheus_alert, 2, project: projects[0])
create(:prometheus_alert, project: projects[1])
create(:zoom_meeting, project: projects[0], issue: projects[0].issues[0], issue_status: :added)
create_list(:zoom_meeting, 2, project: projects[0], issue: projects[0].issues[1], issue_status: :removed)
@@ -35,6 +39,20 @@ FactoryBot.define do
create_list(:zoom_meeting, 2, project: projects[0], issue: projects[0].issues[2], issue_status: :removed)
create(:sentry_issue, issue: projects[0].issues[0])
+ # Incident Labeled Issues
+ incident_label_attrs = IncidentManagement::CreateIssueService::INCIDENT_LABEL
+ incident_label = create(:label, project: projects[0], **incident_label_attrs)
+ 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)
+ create(:labeled_issue, project: projects[1], labels: [incident_label_scoped_to_project])
+ create(:labeled_issue, project: projects[1], labels: [incident_label_scoped_to_group])
+
+ # Alert Issues
+ create(:alert_management_alert, issue: issues[0], project: projects[0])
+ create(:alert_management_alert, issue: alert_bot_issues[0], project: projects[0])
+
# Enabled clusters
gcp_cluster = create(:cluster_provider_gcp, :created).cluster
create(:cluster_provider_aws, :created)
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index f274503f0e7..2f5cc404143 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -31,6 +31,10 @@ FactoryBot.define do
user_type { :project_bot }
end
+ trait :migration_bot do
+ user_type { :migration_bot }
+ end
+
trait :external do
external { true }
end
@@ -40,7 +44,7 @@ FactoryBot.define do
end
trait :ghost do
- ghost { true }
+ user_type { :ghost }
after(:build) { |user, _| user.block! }
end
diff --git a/spec/factories/wiki_pages.rb b/spec/factories/wiki_pages.rb
index 8eb7a12a928..e7fcc19bbfe 100644
--- a/spec/factories/wiki_pages.rb
+++ b/spec/factories/wiki_pages.rb
@@ -7,10 +7,17 @@ FactoryBot.define do
transient do
title { generate(:wiki_page_title) }
content { 'Content for wiki page' }
- format { 'markdown' }
- project { create(:project) }
- attrs do
- {
+ format { :markdown }
+ message { nil }
+ project { association(:project, :wiki_repo) }
+ container { project }
+ wiki { association(:wiki, container: container) }
+ page { OpenStruct.new(url_path: title) }
+ end
+
+ initialize_with do
+ new(wiki, page).tap do |page|
+ page.attributes = {
title: title,
content: content,
format: format
@@ -18,27 +25,13 @@ FactoryBot.define do
end
end
- page { OpenStruct.new(url_path: 'some-name') }
- wiki { build(:project_wiki, project: project) }
-
- initialize_with { new(wiki, page) }
-
- before(:create) do |page, evaluator|
- page.attributes = evaluator.attrs
- end
-
- to_create do |page|
- page.create
+ # Clear our default @page, except when using build_stubbed
+ after(:build) do |page|
+ page.instance_variable_set('@page', nil)
end
- trait :with_real_page do
- project { create(:project, :repository) }
-
- page do
- wiki.create_page(title, content)
- page_title, page_dir = wiki.page_title_and_dir(title)
- wiki.wiki.page(title: page_title, dir: page_dir, version: nil)
- end
+ to_create do |page, evaluator|
+ page.create(message: evaluator.message)
end
end
@@ -48,10 +41,10 @@ FactoryBot.define do
trait :for_wiki_page do
transient do
- wiki_page { create(:wiki_page, project: project) }
+ wiki_page { create(:wiki_page, container: project) }
end
- project { @overrides[:wiki_page]&.project || create(:project) }
+ project { @overrides[:wiki_page]&.container || create(:project) }
title { wiki_page.title }
initialize_with do
@@ -73,5 +66,6 @@ FactoryBot.define do
end
sequence(:wiki_page_title) { |n| "Page #{n}" }
+ sequence(:wiki_filename) { |n| "Page_#{n}.md" }
sequence(:sluggified_title) { |n| "slug-#{n}" }
end
diff --git a/spec/factories/wikis.rb b/spec/factories/wikis.rb
new file mode 100644
index 00000000000..96578fdcee6
--- /dev/null
+++ b/spec/factories/wikis.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :wiki do
+ transient do
+ container { association(:project, :wiki_repo) }
+ user { association(:user) }
+ end
+
+ initialize_with { Wiki.for_container(container, user) }
+ skip_create
+
+ factory :project_wiki do
+ transient do
+ project { association(:project, :wiki_repo) }
+ end
+
+ container { project }
+ end
+ end
+end
diff --git a/spec/features/admin/admin_appearance_spec.rb b/spec/features/admin/admin_appearance_spec.rb
index f6c498f7a4c..e711ee7d40e 100644
--- a/spec/features/admin/admin_appearance_spec.rb
+++ b/spec/features/admin/admin_appearance_spec.rb
@@ -12,6 +12,7 @@ describe 'Admin Appearance' do
fill_in 'appearance_title', with: 'MyCompany'
fill_in 'appearance_description', with: 'dev server'
fill_in 'appearance_new_project_guidelines', with: 'Custom project guidelines'
+ fill_in 'appearance_profile_image_guidelines', with: 'Custom profile image guidelines'
click_button 'Update appearance settings'
expect(current_path).to eq admin_appearances_path
@@ -20,6 +21,7 @@ describe 'Admin Appearance' do
expect(page).to have_field('appearance_title', with: 'MyCompany')
expect(page).to have_field('appearance_description', with: 'dev server')
expect(page).to have_field('appearance_new_project_guidelines', with: 'Custom project guidelines')
+ expect(page).to have_field('appearance_profile_image_guidelines', with: 'Custom profile image guidelines')
expect(page).to have_content 'Last edit'
end
@@ -86,6 +88,22 @@ describe 'Admin Appearance' do
expect_custom_new_project_appearance(appearance)
end
+ context 'Profile page with custom profile image guidelines' do
+ before do
+ sign_in(create(:admin))
+ visit admin_appearances_path
+ fill_in 'appearance_profile_image_guidelines', with: 'Custom profile image guidelines, please :smile:!'
+ click_button 'Update appearance settings'
+ end
+
+ it 'renders guidelines when set' do
+ sign_in create(:user)
+ visit profile_path
+
+ expect(page).to have_content 'Custom profile image guidelines, please 😄!'
+ end
+ end
+
it 'Appearance logo' do
sign_in(create(:admin))
visit admin_appearances_path
diff --git a/spec/features/admin/admin_browses_logs_spec.rb b/spec/features/admin/admin_browses_logs_spec.rb
deleted file mode 100644
index 45e860e1536..00000000000
--- a/spec/features/admin/admin_browses_logs_spec.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe 'Admin browses logs' do
- before do
- sign_in(create(:admin))
- end
-
- it 'shows available log files' do
- visit admin_logs_path
-
- expect(page).to have_link 'application_json.log'
- expect(page).to have_link 'git_json.log'
- expect(page).to have_link 'test.log'
- expect(page).to have_link 'sidekiq.log'
- expect(page).to have_link 'repocheck.log'
- expect(page).to have_link 'kubernetes.log'
- end
-end
diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb
index 64326f3be32..40bcf4a31e4 100644
--- a/spec/features/admin/admin_hooks_spec.rb
+++ b/spec/features/admin/admin_hooks_spec.rb
@@ -36,6 +36,24 @@ describe 'Admin::Hooks' do
expect(page).to have_content('foo.rb')
expect(page).to have_content('bar.clj')
end
+
+ context 'deprecation warning' do
+ it 'shows warning for plugins directory' do
+ allow(Gitlab::FileHook).to receive(:files).and_return(['plugins/foo.rb'])
+
+ visit admin_hooks_path
+
+ expect(page).to have_content('Plugins directory is deprecated and will be removed in 14.0')
+ end
+
+ it 'does not show warning for file_hooks directory' do
+ allow(Gitlab::FileHook).to receive(:files).and_return(['file_hooks/foo.rb'])
+
+ visit admin_hooks_path
+
+ expect(page).not_to have_content('Plugins directory is deprecated and will be removed in 14.0')
+ end
+ end
end
describe 'New Hook' do
diff --git a/spec/features/admin/admin_mode/login_spec.rb b/spec/features/admin/admin_mode/login_spec.rb
index b8a910d3a40..afc6f2ddb56 100644
--- a/spec/features/admin/admin_mode/login_spec.rb
+++ b/spec/features/admin/admin_mode/login_spec.rb
@@ -5,6 +5,7 @@ require 'spec_helper'
describe 'Admin Mode Login', :clean_gitlab_redis_shared_state, :do_not_mock_admin_mode do
include TermsHelper
include UserLoginHelper
+ include LdapHelpers
describe 'with two-factor authentication', :js do
def enter_code(code)
@@ -179,6 +180,82 @@ describe 'Admin Mode Login', :clean_gitlab_redis_shared_state, :do_not_mock_admi
gitlab_enable_admin_mode_sign_in_via('saml', user, 'my-uid', mock_saml_response)
end
end
+
+ context 'when logging in via ldap' do
+ let(:uid) { 'my-uid' }
+ let(:provider_label) { 'Main LDAP' }
+ let(:provider_name) { 'main' }
+ let(:provider) { "ldap#{provider_name}" }
+ let(:ldap_server_config) do
+ {
+ 'label' => provider_label,
+ 'provider_name' => provider,
+ 'attributes' => {},
+ 'encryption' => 'plain',
+ 'uid' => 'uid',
+ 'base' => 'dc=example,dc=com'
+ }
+ end
+ let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: uid, provider: provider) }
+
+ before do
+ setup_ldap(provider, user, uid, ldap_server_config)
+ end
+
+ context 'when two factor authentication is required' do
+ it 'shows 2FA prompt after ldap login' do
+ sign_in_using_ldap!(user, provider_label)
+
+ expect(page).to have_content('Two-Factor Authentication')
+
+ enter_code(user.current_otp)
+ enable_admin_mode_using_ldap!(user)
+
+ expect(page).to have_content('Two-Factor Authentication')
+
+ # Cannot reuse the TOTP
+ Timecop.travel(30.seconds.from_now) do
+ enter_code(user.current_otp)
+
+ expect(current_path).to eq admin_root_path
+ expect(page).to have_content('Admin mode enabled')
+ end
+ end
+ end
+
+ def setup_ldap(provider, user, uid, ldap_server_config)
+ stub_ldap_setting(enabled: true)
+
+ allow(::Gitlab::Auth::Ldap::Config).to receive_messages(enabled: true, servers: [ldap_server_config])
+ allow(Gitlab::Auth::OAuth::Provider).to receive_messages(providers: [provider.to_sym])
+
+ Ldap::OmniauthCallbacksController.define_providers!
+ Rails.application.reload_routes!
+
+ mock_auth_hash(provider, uid, user.email)
+ allow(Gitlab::Auth::Ldap::Access).to receive(:allowed?).with(user).and_return(true)
+
+ allow_any_instance_of(ActionDispatch::Routing::RoutesProxy)
+ .to receive(:"user_#{provider}_omniauth_callback_path")
+ .and_return("/users/auth/#{provider}/callback")
+ end
+
+ def sign_in_using_ldap!(user, provider_label)
+ visit new_user_session_path
+ click_link provider_label
+ fill_in 'username', with: user.username
+ fill_in 'password', with: user.password
+ click_button 'Sign in'
+ end
+
+ def enable_admin_mode_using_ldap!(user)
+ visit new_admin_session_path
+ click_link provider_label
+ fill_in 'username', with: user.username
+ fill_in 'password', with: user.password
+ click_button 'Enter Admin Mode'
+ end
+ end
end
end
end
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index 1a3da8cb373..7ec3c2abb51 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -212,12 +212,12 @@ describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_not_moc
expect(current_settings.hide_third_party_offers).to be true
end
- it 'Change Slack Notifications Service template settings' do
+ it 'Change Slack Notifications Service template settings', :js do
first(:link, 'Service Templates').click
click_link 'Slack notifications'
fill_in 'Webhook', with: 'http://localhost'
fill_in 'Username', with: 'test_user'
- fill_in 'service_push_channel', with: '#test_channel'
+ fill_in 'service[push_channel]', with: '#test_channel'
page.check('Notify only broken pipelines')
page.select 'All branches', from: 'Branches to be notified'
@@ -231,10 +231,10 @@ describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_not_moc
expect(page.all('input[type=checkbox]')).to all(be_checked)
expect(find_field('Webhook').value).to eq 'http://localhost'
expect(find_field('Username').value).to eq 'test_user'
- expect(find('#service_push_channel').value).to eq '#test_channel'
+ expect(find('[name="service[push_channel]"]').value).to eq '#test_channel'
end
- it 'defaults Deployment events to false for chat notification template settings' do
+ it 'defaults Deployment events to false for chat notification template settings', :js do
first(:link, 'Service Templates').click
click_link 'Slack notifications'
@@ -302,16 +302,6 @@ describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_not_moc
visit metrics_and_profiling_admin_application_settings_path
end
- it 'Change Influx settings' do
- page.within('.as-influx') do
- check 'Enable InfluxDB Metrics'
- click_button 'Save changes'
- end
-
- expect(current_settings.metrics_enabled?).to be true
- expect(page).to have_content "Application settings saved successfully"
- end
-
it 'Change Prometheus settings' do
page.within('.as-prometheus') do
check 'Enable Prometheus Metrics'
@@ -382,6 +372,18 @@ describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_not_moc
expect(current_settings.allow_local_requests_from_system_hooks).to be false
expect(current_settings.dns_rebinding_protection_enabled).to be false
end
+
+ it 'Changes Issues rate limits settings' do
+ visit network_admin_application_settings_path
+
+ page.within('.as-issue-limits') do
+ fill_in 'Max requests per second per user', with: 0
+ click_button 'Save changes'
+ end
+
+ expect(page).to have_content "Application settings saved successfully"
+ expect(current_settings.issues_create_limit).to eq(0)
+ end
end
context 'Preferences page' do
@@ -498,13 +500,13 @@ describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_not_moc
def check_all_events
page.check('Push')
page.check('Issue')
- page.check('Confidential issue')
- page.check('Merge request')
+ page.check('Confidential Issue')
+ page.check('Merge Request')
page.check('Note')
- page.check('Confidential note')
- page.check('Tag push')
+ page.check('Confidential Note')
+ page.check('Tag Push')
page.check('Pipeline')
- page.check('Wiki page')
+ page.check('Wiki Page')
page.check('Deployment')
end
diff --git a/spec/features/admin/admin_users_impersonation_tokens_spec.rb b/spec/features/admin/admin_users_impersonation_tokens_spec.rb
index 27f2436108c..b9de858e3b9 100644
--- a/spec/features/admin/admin_users_impersonation_tokens_spec.rb
+++ b/spec/features/admin/admin_users_impersonation_tokens_spec.rb
@@ -70,7 +70,7 @@ describe 'Admin > Users > Impersonation Tokens', :js do
accept_confirm { click_on "Revoke" }
expect(page).to have_selector(".settings-message")
- expect(no_personal_access_tokens_message).to have_text("This user has no active Impersonation Tokens.")
+ expect(no_personal_access_tokens_message).to have_text("This user has no active impersonation tokens.")
end
it "removes expired tokens from 'active' section" do
@@ -79,7 +79,7 @@ describe 'Admin > Users > Impersonation Tokens', :js do
visit admin_user_impersonation_tokens_path(user_id: user.username)
expect(page).to have_selector(".settings-message")
- expect(no_personal_access_tokens_message).to have_text("This user has no active Impersonation Tokens.")
+ expect(no_personal_access_tokens_message).to have_text("This user has no active impersonation tokens.")
end
end
end
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
index 0ac8e7c5fc8..e82b1be4310 100644
--- a/spec/features/boards/boards_spec.rb
+++ b/spec/features/boards/boards_spec.rb
@@ -137,6 +137,49 @@ describe 'Issue Boards', :js do
expect(find('.board:nth-child(4)')).to have_selector('.board-card', count: 0)
end
+ context 'search list negation queries' do
+ context 'with the NOT queries feature flag disabled' do
+ before do
+ stub_feature_flags(not_issuable_queries: false)
+ visit project_board_path(project, board)
+ end
+
+ it 'does not have the != option' do
+ find('.filtered-search').set('label:')
+
+ wait_for_requests
+ within('#js-dropdown-operator') do
+ tokens = all(:css, 'li.filter-dropdown-item')
+ expect(tokens.count).to eq(1)
+ button = tokens[0].find('button')
+ expect(button).to have_content('=')
+ expect(button).not_to have_content('!=')
+ end
+ end
+ end
+
+ context 'with the NOT queries feature flag enabled' do
+ before do
+ stub_feature_flags(not_issuable_queries: true)
+ visit project_board_path(project, board)
+ end
+
+ it 'does not have the != option' do
+ find('.filtered-search').set('label:')
+
+ wait_for_requests
+ within('#js-dropdown-operator') do
+ tokens = all(:css, 'li.filter-dropdown-item')
+ expect(tokens.count).to eq(2)
+ button = tokens[0].find('button')
+ expect(button).to have_content('=')
+ button = tokens[1].find('button')
+ expect(button).to have_content('!=')
+ end
+ end
+ end
+ end
+
it 'allows user to delete board' do
page.within(find('.board:nth-child(2)')) do
accept_confirm { find('.board-delete').click }
@@ -549,6 +592,17 @@ describe 'Issue Boards', :js do
end
end
+ context 'issue board focus mode' do
+ before do
+ visit project_board_path(project, board)
+ wait_for_requests
+ end
+
+ it 'shows the button' do
+ expect(page).to have_link('Toggle focus mode')
+ end
+ end
+
context 'keyboard shortcuts' do
before do
visit project_board_path(project, board)
diff --git a/spec/features/boards/focus_mode_spec.rb b/spec/features/boards/focus_mode_spec.rb
new file mode 100644
index 00000000000..fff3cce3c1a
--- /dev/null
+++ b/spec/features/boards/focus_mode_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Issue Boards focus mode', :js do
+ let(:project) { create(:project, :public) }
+
+ before do
+ visit project_boards_path(project)
+
+ wait_for_requests
+ end
+
+ it 'shows focus mode button to guest users' do
+ expect(page).to have_selector('.board-extra-actions .js-focus-mode-btn')
+ end
+end
diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb
index f30689240c5..d05709b7e2f 100644
--- a/spec/features/boards/sidebar_spec.rb
+++ b/spec/features/boards/sidebar_spec.rb
@@ -217,7 +217,7 @@ describe 'Issue Boards', :js do
wait_for_requests
- click_link "No Milestone"
+ click_link "No milestone"
wait_for_requests
diff --git a/spec/features/commits/user_view_commits_spec.rb b/spec/features/commits/user_view_commits_spec.rb
new file mode 100644
index 00000000000..133baca8b1c
--- /dev/null
+++ b/spec/features/commits/user_view_commits_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Commit > User view commits' do
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:user) { project.creator }
+
+ before do
+ visit project_commits_path(project)
+ end
+
+ describe 'Commits List' do
+ it 'displays the correct number of commits per day in the header' do
+ expect(first('.js-commit-header').find('.commits-count').text).to eq('1 commit')
+ end
+
+ it 'lists the correct number of commits' do
+ expect(page).to have_selector('#commits-list > li:nth-child(2) > ul', count: 1)
+ end
+ end
+end
diff --git a/spec/features/dashboard/help_spec.rb b/spec/features/dashboard/help_spec.rb
deleted file mode 100644
index 3f425001447..00000000000
--- a/spec/features/dashboard/help_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Dashboard Help' do
- before do
- sign_in(create(:user))
- end
-
- context 'documentation' do
- it 'renders correctly markdown' do
- visit help_page_path("administration/raketasks/maintenance")
-
- expect(page).to have_content('Gather information about GitLab and the system it runs on')
-
- node = find('.documentation h2 a#user-content-check-gitlab-configuration')
- expect(node[:href]).to eq '#check-gitlab-configuration'
- expect(find(:xpath, "#{node.path}/..").text).to eq 'Check GitLab configuration'
- end
- end
-end
diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb
index ff661014fb9..0b2811618b5 100644
--- a/spec/features/dashboard/issues_spec.rb
+++ b/spec/features/dashboard/issues_spec.rb
@@ -73,7 +73,7 @@ RSpec.describe 'Dashboard Issues' do
find('.new-project-item-link').click
- expect(page).to have_current_path("#{project_path}/issues/new")
+ expect(page).to have_current_path("#{project_path}/-/issues/new")
page.within('#content-body') do
expect(page).to have_selector('.issue-form')
diff --git a/spec/features/dashboard/snippets_spec.rb b/spec/features/dashboard/snippets_spec.rb
index 287af7e7b11..94aef03e093 100644
--- a/spec/features/dashboard/snippets_spec.rb
+++ b/spec/features/dashboard/snippets_spec.rb
@@ -35,7 +35,7 @@ describe 'Dashboard snippets' do
element = page.find('.row.empty-state')
expect(element).to have_content("Code snippets")
- expect(element.find('.svg-content img')['src']).to have_content('illustrations/snippets_empty')
+ expect(element.find('.svg-content img.js-lazy-loaded')['src']).to have_content('illustrations/snippets_empty')
end
it 'shows new snippet button in main content area' do
@@ -49,47 +49,6 @@ describe 'Dashboard snippets' do
end
end
- context 'rendering file names' do
- let_it_be(:snippet) { create(:personal_snippet, :public, author: user, file_name: 'foo.txt') }
- let_it_be(:versioned_snippet) { create(:personal_snippet, :repository, :public, author: user, file_name: 'bar.txt') }
-
- before do
- sign_in(user)
- end
-
- context 'when feature flag :version_snippets is disabled' do
- before do
- stub_feature_flags(version_snippets: false)
-
- visit dashboard_snippets_path
- end
-
- it 'contains the snippet file names from the DB' do
- aggregate_failures do
- expect(page).to have_content 'foo.txt'
- expect(page).to have_content('bar.txt')
- expect(page).not_to have_content('.gitattributes')
- end
- end
- end
-
- context 'when feature flag :version_snippets is enabled' do
- before do
- stub_feature_flags(version_snippets: true)
-
- visit dashboard_snippets_path
- end
-
- it 'contains both the versioned and non-versioned filenames' do
- aggregate_failures do
- expect(page).to have_content 'foo.txt'
- expect(page).to have_content('.gitattributes')
- expect(page).not_to have_content('bar.txt')
- end
- end
- end
- end
-
context 'filtering by visibility' do
let_it_be(:snippets) do
[
diff --git a/spec/features/dashboard/todos/todos_spec.rb b/spec/features/dashboard/todos/todos_spec.rb
index 867281da1e6..63867d5796a 100644
--- a/spec/features/dashboard/todos/todos_spec.rb
+++ b/spec/features/dashboard/todos/todos_spec.rb
@@ -3,10 +3,10 @@
require 'spec_helper'
describe 'Dashboard Todos' do
- let(:user) { create(:user, username: 'john') }
- let(:author) { create(:user) }
- let(:project) { create(:project, :public) }
- let(:issue) { create(:issue, due_date: Date.today, title: "Fix bug") }
+ let_it_be(:user) { create(:user, username: 'john') }
+ let_it_be(:author) { create(:user) }
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:issue) { create(:issue, due_date: Date.today, title: "Fix bug") }
context 'User does not have todos' do
before do
@@ -357,4 +357,38 @@ describe 'Dashboard Todos' do
expect(page).to have_link "merge request #{todo.target.to_reference}", href: href
end
end
+
+ context 'User has a todo regarding a design' do
+ let_it_be(:target) { create(:design, issue: issue, project: project) }
+ let_it_be(:note) { create(:note, project: project, note: 'I am note, hear me roar') }
+ let_it_be(:todo) do
+ create(:todo, :mentioned,
+ user: user,
+ project: project,
+ target: target,
+ author: author,
+ note: note)
+ end
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+
+ visit dashboard_todos_path
+ end
+
+ it 'has todo present' do
+ expect(page).to have_selector('.todos-list .todo', count: 1)
+ end
+
+ it 'has a link that will take me to the design page' do
+ click_link "design #{target.to_reference}"
+
+ expectation = Gitlab::Routing.url_helpers.designs_project_issue_path(
+ target.project, target.issue, target.filename
+ )
+
+ expect(current_path).to eq(expectation)
+ end
+ end
end
diff --git a/spec/features/error_tracking/user_filters_errors_by_status_spec.rb b/spec/features/error_tracking/user_filters_errors_by_status_spec.rb
index 51e29e2a5ec..4b5bc16c4db 100644
--- a/spec/features/error_tracking/user_filters_errors_by_status_spec.rb
+++ b/spec/features/error_tracking/user_filters_errors_by_status_spec.rb
@@ -6,7 +6,7 @@ describe 'When a user filters Sentry errors by status', :js, :use_clean_rails_me
include_context 'sentry error tracking context feature'
let_it_be(:issues_response_body) { fixture_file('sentry/issues_sample_response.json') }
- let_it_be(:filtered_errors_by_status_response) { JSON.parse(issues_response_body).filter { |error| error['status'] == 'ignored' }.to_json }
+ let_it_be(:filtered_errors_by_status_response) { Gitlab::Json.parse(issues_response_body).filter { |error| error['status'] == 'ignored' }.to_json }
let(:issues_api_url) { "#{sentry_api_urls.issues_url}?limit=20&query=is:unresolved" }
let(:issues_api_url_filter) { "#{sentry_api_urls.issues_url}?limit=20&query=is:ignored" }
let(:auth_token) {{ 'Authorization' => 'Bearer access_token_123' }}
diff --git a/spec/features/error_tracking/user_sees_error_index_spec.rb b/spec/features/error_tracking/user_sees_error_index_spec.rb
index 842e4a2e8b5..34a3a4b5a49 100644
--- a/spec/features/error_tracking/user_sees_error_index_spec.rb
+++ b/spec/features/error_tracking/user_sees_error_index_spec.rb
@@ -6,7 +6,7 @@ describe 'View error index page', :js, :use_clean_rails_memory_store_caching, :s
include_context 'sentry error tracking context feature'
let_it_be(:issues_response_body) { fixture_file('sentry/issues_sample_response.json') }
- let_it_be(:issues_response) { JSON.parse(issues_response_body) }
+ let_it_be(:issues_response) { Gitlab::Json.parse(issues_response_body) }
let(:issues_api_url) { "#{sentry_api_urls.issues_url}?limit=20&query=is:unresolved" }
before do
diff --git a/spec/features/explore/groups_spec.rb b/spec/features/explore/groups_spec.rb
index 50ec44580d2..aee0a7c5573 100644
--- a/spec/features/explore/groups_spec.rb
+++ b/spec/features/explore/groups_spec.rb
@@ -27,7 +27,7 @@ describe 'Explore Groups', :js do
end
before do
- stub_feature_flags({ vue_issuables_list: { enabled: false, thing: group } })
+ stub_feature_flags(vue_issuables_list: false)
end
shared_examples 'renders public and internal projects' do
diff --git a/spec/features/global_search_spec.rb b/spec/features/global_search_spec.rb
index c499fac6bc0..a7c8c29517e 100644
--- a/spec/features/global_search_spec.rb
+++ b/spec/features/global_search_spec.rb
@@ -21,7 +21,7 @@ describe 'Global search' do
describe 'I search through the issues and I see pagination' do
before do
- allow_next_instance_of(Gitlab::SearchResults) do |instance|
+ allow_next_instance_of(SearchService) do |instance|
allow(instance).to receive(:per_page).and_return(1)
end
create_list(:issue, 2, project: project, title: 'initial')
diff --git a/spec/features/groups/import_export/export_file_spec.rb b/spec/features/groups/import_export/export_file_spec.rb
new file mode 100644
index 00000000000..5829e659722
--- /dev/null
+++ b/spec/features/groups/import_export/export_file_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Group Export', :js do
+ include ExportFileHelper
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
+ context 'when the signed in user has the required permission level' do
+ before do
+ group.add_owner(user)
+ sign_in(user)
+ end
+
+ it 'allows the user to export the group', :sidekiq_inline do
+ visit edit_group_path(group)
+
+ expect(page).to have_content('Export group')
+
+ click_link('Export group')
+ expect(page).to have_content('Group export started')
+
+ expect(page).to have_content('Download export')
+ end
+ end
+
+ context 'when the group import/export FF is disabled' do
+ before do
+ stub_feature_flags(group_import_export: false)
+
+ group.add_owner(user)
+ sign_in(user)
+ end
+
+ it 'does not show the group export options' do
+ visit edit_group_path(group)
+
+ expect(page).to have_content('Advanced')
+ expect(page).not_to have_content('Export group')
+ end
+ end
+
+ context 'when the signed in user does not have the required permission level' do
+ before do
+ group.add_guest(user)
+
+ sign_in(user)
+ end
+
+ it 'does not let the user export the group' do
+ visit edit_group_path(group)
+
+ expect(page).to have_content('Page Not Found')
+ expect(page).not_to have_content('Export group')
+ end
+ end
+end
diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb
index e03d7b6d1f7..1cefcd18989 100644
--- a/spec/features/groups/issues_spec.rb
+++ b/spec/features/groups/issues_spec.rb
@@ -12,7 +12,7 @@ describe 'Group issues page' do
let(:path) { issues_group_path(group) }
before do
- stub_feature_flags({ vue_issuables_list: { enabled: false, thing: group } })
+ stub_feature_flags(vue_issuables_list: false)
end
context 'with shared examples' do
@@ -186,4 +186,25 @@ describe 'Group issues page' do
end
end
end
+
+ context 'issues pagination' do
+ let(:user_in_group) { create(:group_member, :maintainer, user: create(:user), group: group ).user }
+
+ let!(:issues) do
+ (1..25).to_a.map { |index| create(:issue, project: project, title: "Issue #{index}") }
+ end
+
+ before do
+ sign_in(user_in_group)
+ visit issues_group_path(group)
+ end
+
+ it 'shows the pagination' do
+ expect(page).to have_selector('.gl-pagination')
+ end
+
+ it 'first pagination item is active' do
+ expect(page).to have_css(".js-first-button a.page-link.active")
+ end
+ end
end
diff --git a/spec/features/groups/members/leave_group_spec.rb b/spec/features/groups/members/leave_group_spec.rb
index df15f995be4..5c7c83aea6d 100644
--- a/spec/features/groups/members/leave_group_spec.rb
+++ b/spec/features/groups/members/leave_group_spec.rb
@@ -31,8 +31,6 @@ describe 'Groups > Members > Leave group' do
page.accept_confirm
- expect(find('.flash-notice')).to have_content "You left the \"#{group.full_name}\" group"
- expect(page).to have_content left_group_message(group)
expect(current_path).to eq(dashboard_groups_path)
expect(group.users).not_to include(user)
end
diff --git a/spec/features/groups/members/manage_groups_spec.rb b/spec/features/groups/members/manage_groups_spec.rb
index 55f9418521f..593c450c6d6 100644
--- a/spec/features/groups/members/manage_groups_spec.rb
+++ b/spec/features/groups/members/manage_groups_spec.rb
@@ -15,75 +15,56 @@ describe 'Groups > Members > Manage groups', :js do
sign_in(user)
end
- context 'with share groups with groups feature flag' do
- before do
- stub_feature_flags(shared_with_group: true)
- end
-
- it 'add group to group' do
- visit group_group_members_path(shared_group)
+ it 'add group to group' do
+ visit group_group_members_path(shared_group)
- add_group(shared_with_group.id, 'Reporter')
+ add_group(shared_with_group.id, 'Reporter')
- page.within(first_row) do
- expect(page).to have_content(shared_with_group.name)
- expect(page).to have_content('Reporter')
- end
+ page.within(first_row) do
+ expect(page).to have_content(shared_with_group.name)
+ expect(page).to have_content('Reporter')
end
+ end
- it 'remove user from group' do
- create(:group_group_link, shared_group: shared_group,
- shared_with_group: shared_with_group, group_access: ::Gitlab::Access::DEVELOPER)
-
- visit group_group_members_path(shared_group)
-
- expect(page).to have_content(shared_with_group.name)
+ it 'remove user from group' do
+ create(:group_group_link, shared_group: shared_group,
+ shared_with_group: shared_with_group, group_access: ::Gitlab::Access::DEVELOPER)
- accept_confirm do
- find(:css, '#existing_shares li', text: shared_with_group.name).find(:css, 'a.btn-remove').click
- end
+ visit group_group_members_path(shared_group)
- wait_for_requests
+ expect(page).to have_content(shared_with_group.name)
- expect(page).not_to have_content(shared_with_group.name)
+ accept_confirm do
+ find(:css, '#existing_shares li', text: shared_with_group.name).find(:css, 'a.btn-remove').click
end
- it 'update group to owner level' do
- create(:group_group_link, shared_group: shared_group,
- shared_with_group: shared_with_group, group_access: ::Gitlab::Access::DEVELOPER)
+ wait_for_requests
- visit group_group_members_path(shared_group)
+ expect(page).not_to have_content(shared_with_group.name)
+ end
- page.within(first_row) do
- click_button('Developer')
- click_link('Maintainer')
+ it 'update group to owner level' do
+ create(:group_group_link, shared_group: shared_group,
+ shared_with_group: shared_with_group, group_access: ::Gitlab::Access::DEVELOPER)
- wait_for_requests
+ visit group_group_members_path(shared_group)
- expect(page).to have_button('Maintainer')
- end
- end
+ page.within(first_row) do
+ click_button('Developer')
+ click_link('Maintainer')
- def add_group(id, role)
- page.click_link 'Invite group'
- page.within ".invite-group-form" do
- select2(id, from: "#shared_with_group_id")
- select(role, from: "shared_group_access")
- click_button "Invite"
- end
- end
- end
+ wait_for_requests
- context 'without share groups with groups feature flag' do
- before do
- stub_feature_flags(share_group_with_group: false)
+ expect(page).to have_button('Maintainer')
end
+ end
- it 'does not render invitation form and tabs' do
- visit group_group_members_path(shared_group)
-
- expect(page).not_to have_link('Invite member')
- expect(page).not_to have_link('Invite group')
+ def add_group(id, role)
+ page.click_link 'Invite group'
+ page.within ".invite-group-form" do
+ select2(id, from: "#shared_with_group_id")
+ select(role, from: "shared_group_access")
+ click_button "Invite"
end
end
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
new file mode 100644
index 00000000000..491937ce4ab
--- /dev/null
+++ b/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Groups > Members > Owner adds member with expiration date', :js do
+ include Select2Helper
+ include ActiveSupport::Testing::TimeHelpers
+
+ let(:user1) { create(:user, name: 'John Doe') }
+ let!(:new_member) { create(:user, name: 'Mary Jane') }
+ let(:group) { create(:group) }
+
+ before do
+ group.add_owner(user1)
+ sign_in(user1)
+ end
+
+ it 'expiration date is displayed in the members list' do
+ travel_to Time.zone.parse('2016-08-06 08:00') do
+ date = 4.days.from_now
+ visit group_group_members_path(group)
+
+ page.within '.invite-users-form' do
+ select2(new_member.id, from: '#user_ids', multiple: true)
+ fill_in 'expires_at', with: date.to_s(:medium) + "\n"
+ click_on 'Invite'
+ end
+
+ page.within "#group_member_#{group_member_id(new_member)}" do
+ expect(page).to have_content('Expires in 4 days')
+ end
+ end
+ end
+
+ it 'change expiration date' do
+ travel_to Time.zone.parse('2016-08-06 08:00') do
+ date = 3.days.from_now
+ group.add_developer(new_member)
+
+ visit group_group_members_path(group)
+
+ page.within "#group_member_#{group_member_id(new_member)}" do
+ find('.js-access-expiration-date').set date.to_s(:medium) + "\n"
+ wait_for_requests
+ expect(page).to have_content('Expires in 3 days')
+ end
+ end
+ end
+
+ it 'remove expiration date' do
+ travel_to Time.zone.parse('2016-08-06 08:00') do
+ date = 3.days.from_now
+ group_member = create(:group_member, :developer, user: new_member, group: group, expires_at: date.to_s(:medium))
+
+ visit group_group_members_path(group)
+
+ page.within "#group_member_#{group_member.id}" do
+ find('.js-clear-input').click
+ wait_for_requests
+ expect(page).not_to have_content('Expires in 3 days')
+ end
+ end
+ end
+
+ def group_member_id(user)
+ group.members.find_by(user_id: new_member).id
+ end
+end
diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb
index 0cdc2aa88f4..fd5b4ec9345 100644
--- a/spec/features/groups/navbar_spec.rb
+++ b/spec/features/groups/navbar_spec.rb
@@ -10,7 +10,42 @@ describe 'Group navbar' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
+ let(:structure) do
+ [
+ {
+ nav_item: _('Group overview'),
+ nav_sub_items: [
+ _('Details'),
+ _('Activity')
+ ]
+ },
+ {
+ nav_item: _('Issues'),
+ nav_sub_items: [
+ _('List'),
+ _('Board'),
+ _('Labels'),
+ _('Milestones')
+ ]
+ },
+ {
+ nav_item: _('Merge Requests'),
+ nav_sub_items: []
+ },
+ {
+ nav_item: _('Kubernetes'),
+ nav_sub_items: []
+ },
+ (analytics_nav_item if Gitlab.ee?),
+ {
+ nav_item: _('Members'),
+ nav_sub_items: []
+ }
+ ]
+ end
+
before do
+ stub_feature_flags(group_push_rules: false)
group.add_maintainer(user)
sign_in(user)
end
@@ -20,4 +55,21 @@ describe 'Group navbar' do
visit group_path(group)
end
end
+
+ context 'when container registry is available' do
+ before do
+ stub_config(registry: { enabled: true })
+
+ insert_after_nav_item(
+ _('Kubernetes'),
+ new_nav_item: {
+ nav_item: _('Packages & Registries'),
+ nav_sub_items: [_('Container Registry')]
+ }
+ )
+ visit group_path(group)
+ end
+
+ it_behaves_like 'verified navigation bar'
+ end
end
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index d2e65c02e37..c1cb0b4951e 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -262,6 +262,42 @@ describe 'Group' do
end
end
+ describe 'new subgroup / project button' do
+ let(:group) { create(:group, project_creation_level: Gitlab::Access::NO_ONE_PROJECT_ACCESS, subgroup_creation_level: Gitlab::Access::OWNER_SUBGROUP_ACCESS) }
+
+ it 'new subgroup button is displayed without project creation permission' do
+ visit group_path(group)
+
+ page.within '.group-buttons' do
+ expect(page).to have_link('New subgroup')
+ end
+ end
+
+ it 'new subgroup button is displayed together with new project button when having project creation permission' do
+ group.update!(project_creation_level: Gitlab::Access::MAINTAINER_PROJECT_ACCESS)
+ visit group_path(group)
+
+ page.within '.group-buttons' do
+ expect(page).to have_css("li[data-text='New subgroup']", visible: false)
+ expect(page).to have_css("li[data-text='New project']", visible: false)
+ end
+ end
+
+ it 'new project button is displayed without subgroup creation permission' do
+ group.update!(project_creation_level: Gitlab::Access::MAINTAINER_PROJECT_ACCESS)
+ user = create(:user)
+
+ group.add_maintainer(user)
+ sign_out(:user)
+ sign_in(user)
+
+ visit group_path(group)
+ page.within '.group-buttons' do
+ expect(page).to have_link('New project')
+ end
+ end
+ end
+
def remove_with_confirm(button_text, confirm_with)
click_button button_text
fill_in 'confirm_name_input', with: confirm_with
diff --git a/spec/features/help_pages_spec.rb b/spec/features/help_pages_spec.rb
index 88a7aa51326..1ba3849fe2c 100644
--- a/spec/features/help_pages_spec.rb
+++ b/spec/features/help_pages_spec.rb
@@ -4,35 +4,9 @@ require 'spec_helper'
describe 'Help Pages' do
describe 'Get the main help page' do
- shared_examples_for 'help page' do |prefix: ''|
- it 'prefixes links correctly' do
- expect(page).to have_selector(%(div.documentation-index > table tbody tr td a[href="#{prefix}/help/api/README.md"]))
- end
- end
-
- context 'without a trailing slash' do
- before do
- visit help_path
- end
-
- it_behaves_like 'help page'
- end
-
- context 'with a trailing slash' do
- before do
- visit help_path + '/'
- end
-
- it_behaves_like 'help page'
- end
-
- context 'with a relative installation' do
- before do
- stub_config_setting(relative_url_root: '/gitlab')
- visit help_path
- end
-
- it_behaves_like 'help page', prefix: '/gitlab'
+ before do
+ allow(File).to receive(:read).and_call_original
+ allow(File).to receive(:read).with(Rails.root.join('doc', 'README.md')).and_return(fixture_file('sample_doc.md'))
end
context 'quick link shortcuts', :js do
diff --git a/spec/features/issues/bulk_assignment_labels_spec.rb b/spec/features/issues/bulk_assignment_labels_spec.rb
index d036fde5657..fc9176715c3 100644
--- a/spec/features/issues/bulk_assignment_labels_spec.rb
+++ b/spec/features/issues/bulk_assignment_labels_spec.rb
@@ -274,7 +274,7 @@ describe 'Issues > Labels bulk assignment' do
expect(find("#issue_#{issue2.id}")).to have_content 'First Release'
check 'check-all-issues'
- open_milestone_dropdown(['No Milestone'])
+ open_milestone_dropdown(['No milestone'])
update_issues
expect(find("#issue_#{issue1.id}")).to have_content 'bug'
diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb
index 5e2b5921e06..3ee5840e1b9 100644
--- a/spec/features/issues/filtered_search/filter_issues_spec.rb
+++ b/spec/features/issues/filtered_search/filter_issues_spec.rb
@@ -81,6 +81,26 @@ describe 'Filter issues', :js do
expect_filtered_search_input(search_term)
end
+ context 'with the NOT queries feature flag disabled' do
+ before do
+ stub_feature_flags(not_issuable_queries: false)
+ visit project_issues_path(project)
+ end
+
+ it 'does not have the != option' do
+ input_filtered_search("label:", submit: false)
+
+ wait_for_requests
+ within('#js-dropdown-operator') do
+ tokens = all(:css, 'li.filter-dropdown-item')
+ expect(tokens.count).to eq(1)
+ button = tokens[0].find('button')
+ expect(button).to have_content('=')
+ expect(button).not_to have_content('!=')
+ end
+ end
+ end
+
describe 'filter issues by author' do
context 'only author' do
it 'filters issues by searched author' do
@@ -153,16 +173,16 @@ describe 'Filter issues', :js do
expect_filtered_search_input_empty
end
- it 'filters issues by no label' do
- input_filtered_search('label:=none')
+ it 'filters issues by any label' do
+ input_filtered_search('label:=any')
- expect_tokens([label_token('None', false)])
+ expect_tokens([label_token('Any', false)])
expect_issues_list_count(4)
expect_filtered_search_input_empty
end
it 'filters issues by no label' do
- input_filtered_search('label:!=none')
+ input_filtered_search('label:=none')
expect_tokens([label_token('None', false)])
expect_issues_list_count(4)
@@ -351,14 +371,6 @@ describe 'Filter issues', :js do
expect_filtered_search_input_empty
end
- it 'filters issues by negation of no milestone' do
- input_filtered_search("milestone:!=none ")
-
- expect_tokens([milestone_token('None', false, '!=')])
- expect_issues_list_count(5)
- expect_filtered_search_input_empty
- end
-
it 'filters issues by upcoming milestones' do
create(:milestone, project: project, due_date: 1.month.from_now) do |future_milestone|
create(:issue, project: project, milestone: future_milestone, author: user)
@@ -376,10 +388,14 @@ describe 'Filter issues', :js do
create(:issue, project: project, milestone: future_milestone, author: user)
end
+ create(:milestone, project: project, due_date: 3.days.ago) do |past_milestone|
+ create(:issue, project: project, milestone: past_milestone, author: user)
+ end
+
input_filtered_search("milestone:!=upcoming")
expect_tokens([milestone_token('Upcoming', false, '!=')])
- expect_issues_list_count(8)
+ expect_issues_list_count(1)
expect_filtered_search_input_empty
end
@@ -392,10 +408,13 @@ describe 'Filter issues', :js do
end
it 'filters issues by negation of started milestones' do
+ milestone2 = create(:milestone, title: "9", project: project, start_date: 2.weeks.from_now)
+ create(:issue, project: project, author: user, title: "something else", milestone: milestone2)
+
input_filtered_search("milestone:!=started")
expect_tokens([milestone_token('Started', false, '!=')])
- expect_issues_list_count(3)
+ expect_issues_list_count(1)
expect_filtered_search_input_empty
end
diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb
index 3c50cb4c997..d34253b3c5e 100644
--- a/spec/features/issues/filtered_search/visual_tokens_spec.rb
+++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb
@@ -113,7 +113,7 @@ describe 'Visual tokens', :js do
describe 'add new token after editing existing token' do
before do
input_filtered_search('author:=@root assignee:=none', submit: false)
- first('.tokens-container .filtered-search-token').double_click
+ first('.tokens-container .filtered-search-token').click
filtered_search.send_keys(' ')
end
@@ -175,4 +175,20 @@ describe 'Visual tokens', :js do
expect(token.find('.name').text).to eq('Label')
expect(token.find('.operator').text).to eq('=')
end
+
+ describe 'Any/None option' do
+ it 'hidden when NOT operator is selected' do
+ input_filtered_search('milestone:!=', extra_space: false, submit: false)
+
+ expect(page).not_to have_selector("#js-dropdown-milestone", text: 'Any')
+ expect(page).not_to have_selector("#js-dropdown-milestone", text: 'None')
+ end
+
+ it 'shown when EQUAL operator is selected' do
+ input_filtered_search('milestone:=', extra_space: false, submit: false)
+
+ expect(page).to have_selector("#js-dropdown-milestone", text: 'Any')
+ expect(page).to have_selector("#js-dropdown-milestone", text: 'None')
+ end
+ end
end
diff --git a/spec/features/issues/spam_issues_spec.rb b/spec/features/issues/spam_issues_spec.rb
index b59cd2d632a..47e7022011d 100644
--- a/spec/features/issues/spam_issues_spec.rb
+++ b/spec/features/issues/spam_issues_spec.rb
@@ -23,9 +23,13 @@ describe 'New issue', :js do
sign_in(user)
end
- context 'when identified as spam' do
+ context 'when SpamVerdictService disallows' do
+ include_context 'includes Spam constants'
+
before do
- WebMock.stub_request(:any, /.*akismet.com.*/).to_return(body: "true", status: 200)
+ allow_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ allow(verdict_service).to receive(:execute).and_return(DISALLOW)
+ end
visit new_project_issue_path(project)
end
@@ -33,23 +37,22 @@ describe 'New issue', :js do
context 'when allow_possible_spam feature flag is false' do
before do
stub_feature_flags(allow_possible_spam: false)
- end
- it 'creates an issue after solving reCaptcha' do
fill_in 'issue_title', with: 'issue title'
fill_in 'issue_description', with: 'issue description'
+ end
+ it 'rejects issue creation' do
click_button 'Submit issue'
- # it is impossible to test recaptcha automatically and there is no possibility to fill in recaptcha
- # recaptcha verification is skipped in test environment and it always returns true
+ expect(page).to have_content('discarded')
+ expect(page).not_to have_content('potential spam')
expect(page).not_to have_content('issue title')
- expect(page).to have_css('.recaptcha')
-
- click_button 'Submit issue'
+ end
- expect(page.find('.issue-details h2.title')).to have_content('issue title')
- expect(page.find('.issue-details .description')).to have_content('issue description')
+ it 'creates a spam log record' do
+ expect { click_button 'Submit issue' }
+ .to log_spam(title: 'issue title', description: 'issue description', user_id: user.id, noteable_type: 'Issue')
end
end
@@ -59,10 +62,9 @@ describe 'New issue', :js do
fill_in 'issue_description', with: 'issue description'
end
- it 'creates an issue without a need to solve reCaptcha' do
+ it 'allows issue creation' do
click_button 'Submit issue'
- expect(page).not_to have_css('.recaptcha')
expect(page.find('.issue-details h2.title')).to have_content('issue title')
expect(page.find('.issue-details .description')).to have_content('issue description')
end
@@ -74,9 +76,98 @@ describe 'New issue', :js do
end
end
- context 'when not identified as spam' do
+ context 'when SpamVerdictService requires recaptcha' do
+ include_context 'includes Spam constants'
+
before do
- WebMock.stub_request(:any, /.*akismet.com.*/).to_return(body: 'false', status: 200)
+ allow_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ allow(verdict_service).to receive(:execute).and_return(REQUIRE_RECAPTCHA)
+ end
+
+ visit new_project_issue_path(project)
+ end
+
+ context 'when recaptcha is enabled' do
+ before do
+ stub_application_setting(recaptcha_enabled: true)
+ end
+
+ context 'when allow_possible_spam feature flag is false' do
+ before do
+ stub_feature_flags(allow_possible_spam: false)
+ end
+
+ it 'creates an issue after solving reCaptcha' do
+ fill_in 'issue_title', with: 'issue title'
+ fill_in 'issue_description', with: 'issue description'
+
+ click_button 'Submit issue'
+
+ # it is impossible to test reCAPTCHA automatically and there is no possibility to fill in recaptcha
+ # reCAPTCHA verification is skipped in test environment and it always returns true
+ expect(page).not_to have_content('issue title')
+ expect(page).to have_css('.recaptcha')
+
+ click_button 'Submit issue'
+
+ expect(page.find('.issue-details h2.title')).to have_content('issue title')
+ expect(page.find('.issue-details .description')).to have_content('issue description')
+ end
+ end
+
+ context 'when allow_possible_spam feature flag is true' do
+ before do
+ fill_in 'issue_title', with: 'issue title'
+ fill_in 'issue_description', with: 'issue description'
+ end
+
+ it 'creates an issue without a need to solve reCAPTCHA' do
+ click_button 'Submit issue'
+
+ expect(page).not_to have_css('.recaptcha')
+ expect(page.find('.issue-details h2.title')).to have_content('issue title')
+ expect(page.find('.issue-details .description')).to have_content('issue description')
+ end
+
+ it 'creates a spam log record' do
+ expect { click_button 'Submit issue' }
+ .to log_spam(title: 'issue title', description: 'issue description', user_id: user.id, noteable_type: 'Issue')
+ end
+ end
+ end
+
+ context 'when reCAPTCHA is not enabled' do
+ before do
+ stub_application_setting(recaptcha_enabled: false)
+ end
+
+ context 'when allow_possible_spam feature flag is true' do
+ before do
+ fill_in 'issue_title', with: 'issue title'
+ fill_in 'issue_description', with: 'issue description'
+ end
+
+ it 'creates an issue without a need to solve reCaptcha' do
+ click_button 'Submit issue'
+
+ expect(page).not_to have_css('.recaptcha')
+ expect(page.find('.issue-details h2.title')).to have_content('issue title')
+ expect(page.find('.issue-details .description')).to have_content('issue description')
+ end
+
+ it 'creates a spam log record' do
+ expect { click_button 'Submit issue' }
+ .to log_spam(title: 'issue title', description: 'issue description', user_id: user.id, noteable_type: 'Issue')
+ end
+ end
+ end
+ end
+
+ context 'when the SpamVerdictService allows' do
+ before do
+ allow_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ allow(verdict_service).to receive(:execute).and_return(ALLOW)
+ end
visit new_project_issue_path(project)
end
diff --git a/spec/features/issues/update_issues_spec.rb b/spec/features/issues/update_issues_spec.rb
index 45a0b1932a2..98f70df1c8b 100644
--- a/spec/features/issues/update_issues_spec.rb
+++ b/spec/features/issues/update_issues_spec.rb
@@ -95,7 +95,7 @@ describe 'Multiple issue updating from issues#index', :js do
find('#check-all-issues').click
find('.issues-bulk-update .js-milestone-select').click
- find('.dropdown-menu-milestone a', text: "No Milestone").click
+ find('.dropdown-menu-milestone a', text: "No milestone").click
click_update_issues_button
expect(find('.issue:first-child')).not_to have_content milestone.title
diff --git a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb
index 7eecfd1ccf4..848dbbb85a6 100644
--- a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb
+++ b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb
@@ -164,17 +164,7 @@ describe 'User creates branch and merge request on issue page', :js do
context 'when issue is confidential' do
let(:issue) { create(:issue, :confidential, project: project) }
- it 'disables the create branch button' do
- stub_feature_flags(create_confidential_merge_request: false)
-
- visit project_issue_path(project, issue)
-
- expect(page).not_to have_css('.create-mr-dropdown-wrap')
- end
-
- it 'enables the create branch button when feature flag is enabled' do
- stub_feature_flags(create_confidential_merge_request: true)
-
+ it 'enables the create branch button' do
visit project_issue_path(project, issue)
expect(page).to have_css('.create-mr-dropdown-wrap')
diff --git a/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb b/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb
new file mode 100644
index 00000000000..c3f17227701
--- /dev/null
+++ b/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Issues > Real-time sidebar', :js do
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:user) { create(:user) }
+
+ before_all do
+ project.add_developer(user)
+ end
+
+ it 'updates the assignee in real-time' do
+ Capybara::Session.new(:other_session)
+
+ using_session :other_session do
+ visit project_issue_path(project, issue)
+ expect(page.find('.assignee')).to have_content 'None'
+ end
+
+ gitlab_sign_in(user)
+ visit project_issue_path(project, issue)
+ expect(page.find('.assignee')).to have_content 'None'
+
+ click_button 'assign yourself'
+
+ using_session :other_session do
+ expect(page.find('.assignee')).to have_content user.name
+ end
+ end
+end
diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb
index f85acc28645..d40c2b8bafd 100644
--- a/spec/features/markdown/copy_as_gfm_spec.rb
+++ b/spec/features/markdown/copy_as_gfm_spec.rb
@@ -173,6 +173,12 @@ describe 'Copy as GFM', :js do
)
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')
diff --git a/spec/features/markdown/metrics_spec.rb b/spec/features/markdown/metrics_spec.rb
index dadb9571c54..7b0eb8959a5 100644
--- a/spec/features/markdown/metrics_spec.rb
+++ b/spec/features/markdown/metrics_spec.rb
@@ -137,7 +137,7 @@ describe 'Metrics rendering', :js, :use_clean_rails_memory_store_caching, :sidek
end
context 'transient metrics embeds' do
- let(:metrics_url) { urls.metrics_project_environment_url(project, environment, embed_json: embed_json) }
+ let(:metrics_url) { urls.metrics_dashboard_project_environment_url(project, environment, embed_json: embed_json) }
let(:title) { 'Important Metrics' }
let(:embed_json) do
{
diff --git a/spec/features/merge_request/maintainer_edits_fork_spec.rb b/spec/features/merge_request/maintainer_edits_fork_spec.rb
index f1ee6aaa897..17ff494a6fa 100644
--- a/spec/features/merge_request/maintainer_edits_fork_spec.rb
+++ b/spec/features/merge_request/maintainer_edits_fork_spec.rb
@@ -20,7 +20,7 @@ describe 'a maintainer edits files on a source-branch of an MR from a fork', :js
end
before do
- stub_feature_flags(web_ide_default: false, single_mr_diff_view: { enabled: false, thing: target_project }, code_navigation: false)
+ stub_feature_flags(web_ide_default: false, single_mr_diff_view: false, code_navigation: false)
target_project.add_maintainer(user)
sign_in(user)
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 8633d67f875..2a4192374bd 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,7 +10,7 @@ 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: { enabled: true, thing: project })
+ stub_feature_flags(single_mr_diff_view: project)
stub_feature_flags(diffs_batch_load: true)
sign_in(project.owner)
diff --git a/spec/features/merge_request/user_posts_diff_notes_spec.rb b/spec/features/merge_request/user_posts_diff_notes_spec.rb
index 19f82058be2..ebfb5ce796f 100644
--- a/spec/features/merge_request/user_posts_diff_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_diff_notes_spec.rb
@@ -235,7 +235,9 @@ describe 'Merge request > User posts diff notes', :js do
def should_allow_dismissing_a_comment(line_holder, diff_side = nil)
write_comment_on_line(line_holder, diff_side)
- find('.js-close-discussion-note-form').click
+ accept_confirm do
+ find('.js-close-discussion-note-form').click
+ end
assert_comment_dismissal(line_holder)
end
diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb
index b22f5a6c211..0548d958322 100644
--- a/spec/features/merge_request/user_posts_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_notes_spec.rb
@@ -147,7 +147,10 @@ describe 'Merge request > User posts notes', :js do
it 'resets the edit note form textarea with the original content of the note if cancelled' do
within('.current-note-edit-form') do
fill_in 'note[note]', with: 'Some new content'
- find('.btn-cancel').click
+
+ accept_confirm do
+ find('.btn-cancel').click
+ end
end
expect(find('.js-note-text').text).to eq ''
end
diff --git a/spec/features/merge_request/user_resolves_conflicts_spec.rb b/spec/features/merge_request/user_resolves_conflicts_spec.rb
index 5fc65f020d3..41a7456aed5 100644
--- a/spec/features/merge_request/user_resolves_conflicts_spec.rb
+++ b/spec/features/merge_request/user_resolves_conflicts_spec.rb
@@ -183,14 +183,14 @@ describe 'Merge request > User resolves conflicts', :js do
end
end
- UNRESOLVABLE_CONFLICTS = {
+ unresolvable_conflicts = {
'conflict-too-large' => 'when the conflicts contain a large file',
'conflict-binary-file' => 'when the conflicts contain a binary file',
'conflict-missing-side' => 'when the conflicts contain a file edited in one branch and deleted in another',
'conflict-non-utf8' => 'when the conflicts contain a non-UTF-8 file'
}.freeze
- UNRESOLVABLE_CONFLICTS.each do |source_branch, description|
+ unresolvable_conflicts.each do |source_branch, description|
context description do
let(:merge_request) { create_merge_request(source_branch) }
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 b8a5a4036a5..0e30df518d7 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
@@ -43,7 +43,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
context 'single thread' do
it 'shows text with how many threads' do
page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 thread resolved')
+ expect(page).to have_content('1 unresolved thread')
end
end
@@ -60,7 +60,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 thread resolved')
+ expect(page).to have_content('All threads resolved')
expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -77,7 +77,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 thread resolved')
+ expect(page).to have_content('All threads resolved')
expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -89,7 +89,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 thread resolved')
+ expect(page).to have_content('1 unresolved thread')
end
end
@@ -162,7 +162,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 thread resolved')
+ expect(page).to have_content('All threads resolved')
end
end
@@ -174,7 +174,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 thread resolved')
+ expect(page).to have_content('1 unresolved thread')
expect(page).not_to have_selector('.line-resolve-btn.is-active')
end
end
@@ -189,7 +189,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 thread resolved')
+ expect(page).to have_content('1 unresolved thread')
end
end
end
@@ -203,7 +203,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 thread resolved')
+ expect(page).to have_content('All threads resolved')
expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -218,7 +218,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 thread resolved')
+ expect(page).to have_content('All threads resolved')
expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -275,7 +275,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
expect(page).to have_content('Last updated')
page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 thread resolved')
+ expect(page).to have_content('1 unresolved thread')
end
end
@@ -292,7 +292,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 thread resolved')
+ expect(page).to have_content('All threads resolved')
end
end
end
@@ -305,7 +305,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
it 'shows text with how many threads' do
page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/2 threads resolved')
+ expect(page).to have_content('2 unresolved threads')
end
end
@@ -313,7 +313,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
click_button('Resolve thread', match: :first)
page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/2 threads resolved')
+ expect(page).to have_content('1 unresolved thread')
end
end
@@ -323,7 +323,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('2/2 threads resolved')
+ expect(page).to have_content('All threads resolved')
expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -336,7 +336,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('2/2 threads resolved')
+ expect(page).to have_content('All threads resolved')
expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -392,7 +392,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
context 'changes tab' do
it 'shows text with how many threads' do
page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 thread resolved')
+ expect(page).to have_content('1 unresolved thread')
end
end
@@ -408,7 +408,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 thread resolved')
+ expect(page).to have_content('All threads resolved')
expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -423,7 +423,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 thread resolved')
+ expect(page).to have_content('All threads resolved')
expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -435,7 +435,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 thread resolved')
+ expect(page).to have_content('1 unresolved thread')
end
end
@@ -449,7 +449,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 thread resolved')
+ expect(page).to have_content('All threads resolved')
expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -466,7 +466,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 thread resolved')
+ expect(page).to have_content('1 unresolved thread')
end
end
end
@@ -489,7 +489,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 thread resolved')
+ expect(page).to have_content('1 unresolved thread')
end
end
@@ -519,7 +519,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 thread resolved')
+ expect(page).to have_content('All threads resolved')
expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -538,7 +538,7 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 thread resolved')
+ expect(page).to have_content('1 unresolved thread')
end
end
end
@@ -550,17 +550,17 @@ describe 'Merge request > User resolves diff notes and threads', :js do
end
it 'shows resolved icon' do
- expect(page).to have_content '1/1 thread resolved'
+ expect(page).to have_content 'All threads resolved'
click_button 'Toggle thread'
expect(page).to have_selector('.line-resolve-btn.is-active')
end
it 'does not allow user to click resolve button' do
- expect(page).to have_selector('.line-resolve-btn.is-disabled')
+ expect(page).to have_selector('.line-resolve-btn.is-active')
click_button 'Toggle thread'
- expect(page).to have_selector('.line-resolve-btn.is-disabled')
+ expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
end
diff --git a/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb
index 9c9e0dacb87..029f55c2cd6 100644
--- a/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb
+++ b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb
@@ -28,6 +28,7 @@ describe 'Merge request > User sees notes from forked project', :js do
page.within('.discussion-notes') do
find('.btn-text-field').click
+ scroll_to(page.find('#note_note', visible: false))
find('#note_note').send_keys('A reply comment')
find('.js-comment-button').click
end
diff --git a/spec/features/merge_requests/user_mass_updates_spec.rb b/spec/features/merge_requests/user_mass_updates_spec.rb
index 4d3461bf1ae..fa951dd50d3 100644
--- a/spec/features/merge_requests/user_mass_updates_spec.rb
+++ b/spec/features/merge_requests/user_mass_updates_spec.rb
@@ -91,7 +91,7 @@ describe 'Merge requests > User mass updates', :js do
end
it 'removes milestone from the merge request' do
- change_milestone("No Milestone")
+ change_milestone("No milestone")
expect(find('.merge-request')).not_to have_content milestone.title
end
diff --git a/spec/features/milestones/user_creates_milestone_spec.rb b/spec/features/milestones/user_creates_milestone_spec.rb
index 368a2ddecdf..12cb27b0062 100644
--- a/spec/features/milestones/user_creates_milestone_spec.rb
+++ b/spec/features/milestones/user_creates_milestone_spec.rb
@@ -14,13 +14,13 @@ describe "User creates milestone", :js do
end
it "creates milestone" do
- TITLE = "v2.3".freeze
+ title = "v2.3".freeze
- fill_in("Title", with: TITLE)
+ fill_in("Title", with: title)
fill_in("Description", with: "# Description header")
click_button("Create milestone")
- expect(page).to have_content(TITLE)
+ expect(page).to have_content(title)
.and have_content("Issues")
.and have_header_with_correct_id_and_link(1, "Description header", "description-header")
diff --git a/spec/features/milestones/user_views_milestone_spec.rb b/spec/features/milestones/user_views_milestone_spec.rb
index d8bb4902087..ca13e226432 100644
--- a/spec/features/milestones/user_views_milestone_spec.rb
+++ b/spec/features/milestones/user_views_milestone_spec.rb
@@ -14,13 +14,13 @@ describe "User views milestone" do
end
it "avoids N+1 database queries" do
- ISSUE_PARAMS = { project: project, assignees: [user], author: user, milestone: milestone, labels: labels }.freeze
+ issue_params = { project: project, assignees: [user], author: user, milestone: milestone, labels: labels }.freeze
- create(:labeled_issue, ISSUE_PARAMS)
+ create(:labeled_issue, issue_params)
control = ActiveRecord::QueryRecorder.new { visit_milestone }
- create(:labeled_issue, ISSUE_PARAMS)
+ create(:labeled_issue, issue_params)
expect { visit_milestone }.not_to exceed_query_limit(control)
end
diff --git a/spec/features/profiles/emails_spec.rb b/spec/features/profiles/emails_spec.rb
index 4d2cd0f8b56..a41ef9e86ae 100644
--- a/spec/features/profiles/emails_spec.rb
+++ b/spec/features/profiles/emails_spec.rb
@@ -31,6 +31,15 @@ describe 'Profile > Emails' do
expect(email).to be_nil
expect(page).to have_content('Email has already been taken')
end
+
+ it 'does not add an invalid email' do
+ fill_in('Email', with: 'test.@example.com')
+ click_button('Add email address')
+
+ email = user.emails.find_by(email: email)
+ expect(email).to be_nil
+ expect(page).to have_content('Email is invalid')
+ end
end
it 'User removes email' do
@@ -58,7 +67,7 @@ describe 'Profile > Emails' do
email = user.emails.create(email: 'my@email.com')
visit profile_emails_path
- expect { click_link("Resend confirmation email") }.to change { ActionMailer::Base.deliveries.size }
+ expect { click_link("Resend confirmation email") }.to have_enqueued_job.on_queue('mailers')
expect(page).to have_content("Confirmation email sent to #{email.email}")
end
diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb
index 22f9c8d8afc..1fb61eeeb5a 100644
--- a/spec/features/profiles/personal_access_tokens_spec.rb
+++ b/spec/features/profiles/personal_access_tokens_spec.rb
@@ -86,7 +86,7 @@ describe 'Profile > Personal Access Tokens', :js do
accept_confirm { click_on "Revoke" }
expect(page).to have_selector(".settings-message")
- expect(no_personal_access_tokens_message).to have_text("This user has no active Personal Access Tokens.")
+ expect(no_personal_access_tokens_message).to have_text("This user has no active personal access tokens.")
end
it "removes expired tokens from 'active' section" do
@@ -94,7 +94,7 @@ describe 'Profile > Personal Access Tokens', :js do
visit profile_personal_access_tokens_path
expect(page).to have_selector(".settings-message")
- expect(no_personal_access_tokens_message).to have_text("This user has no active Personal Access Tokens.")
+ expect(no_personal_access_tokens_message).to have_text("This user has no active personal access tokens.")
end
context "when revocation fails" do
diff --git a/spec/features/projects/activity/user_sees_design_comment_spec.rb b/spec/features/projects/activity/user_sees_design_comment_spec.rb
new file mode 100644
index 00000000000..9864e9ce29f
--- /dev/null
+++ b/spec/features/projects/activity/user_sees_design_comment_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Projects > Activity > User sees design comment', :js do
+ include DesignManagementTestHelpers
+
+ let_it_be(:project) { create(:project, :repository, :public) }
+ let_it_be(:user) { project.creator }
+ let_it_be(:commenter) { create(:user) }
+ let_it_be(:issue) { create(:closed_issue, project: project) }
+ let_it_be(:design) { create(:design, issue: issue) }
+
+ let(:design_activity) do
+ "#{commenter.name} #{commenter.to_reference} commented on design"
+ end
+
+ let(:issue_activity) do
+ "#{user.name} #{user.to_reference} closed issue #{issue.to_reference}"
+ end
+
+ before_all do
+ project.add_developer(commenter)
+ create(:event, :for_design, project: project, author: commenter, design: design)
+ create(:closed_issue_event, project: project, author: user, target: issue)
+ end
+
+ before do
+ enable_design_management
+ end
+
+ it 'shows the design comment action in the activity page' do
+ visit activity_project_path(project)
+
+ expect(page).to have_content(design_activity)
+ end
+
+ it 'allows to filter out the design event with the "event_filter=issue" URL param', :aggregate_failures do
+ visit activity_project_path(project, event_filter: EventFilter::ISSUE)
+
+ expect(page).not_to have_content(design_activity)
+ expect(page).to have_content(issue_activity)
+ end
+
+ it 'allows to filter in the event with the "event_filter=comments" URL param', :aggregate_failures do
+ visit activity_project_path(project, event_filter: EventFilter::COMMENTS)
+
+ expect(page).to have_content(design_activity)
+ expect(page).not_to have_content(issue_activity)
+ end
+end
diff --git a/spec/features/projects/branches/user_creates_branch_spec.rb b/spec/features/projects/branches/user_creates_branch_spec.rb
index 156edb973cd..8aac188160b 100644
--- a/spec/features/projects/branches/user_creates_branch_spec.rb
+++ b/spec/features/projects/branches/user_creates_branch_spec.rb
@@ -16,18 +16,18 @@ describe "User creates branch", :js do
end
it "creates new branch" do
- BRANCH_NAME = "deploy_keys".freeze
+ branch_name = "deploy_keys".freeze
- create_branch(BRANCH_NAME)
+ create_branch(branch_name)
- expect(page).to have_content(BRANCH_NAME)
+ expect(page).to have_content(branch_name)
end
context "when branch name is invalid" do
it "does not create new branch" do
- INVALID_BRANCH_NAME = "1.0 stable".freeze
+ invalid_branch_name = "1.0 stable".freeze
- fill_in("branch_name", with: INVALID_BRANCH_NAME)
+ fill_in("branch_name", with: invalid_branch_name)
page.find("body").click # defocus the branch_name input
select_branch("master")
diff --git a/spec/features/projects/commit/comments/user_edits_comments_spec.rb b/spec/features/projects/commit/comments/user_edits_comments_spec.rb
index 0fa2b2ff232..29132173674 100644
--- a/spec/features/projects/commit/comments/user_edits_comments_spec.rb
+++ b/spec/features/projects/commit/comments/user_edits_comments_spec.rb
@@ -19,7 +19,7 @@ describe "User edits a comment on a commit", :js do
end
it "edits comment" do
- NEW_COMMENT_TEXT = "+1 Awesome!".freeze
+ new_comment_text = "+1 Awesome!".freeze
page.within(".main-notes-list") do
note = find(".note")
@@ -31,14 +31,14 @@ describe "User edits a comment on a commit", :js do
page.find(".current-note-edit-form textarea")
page.within(".current-note-edit-form") do
- fill_in("note[note]", with: NEW_COMMENT_TEXT)
+ fill_in("note[note]", with: new_comment_text)
click_button("Save comment")
end
wait_for_requests
page.within(".note") do
- expect(page).to have_content(NEW_COMMENT_TEXT)
+ expect(page).to have_content(new_comment_text)
end
end
end
diff --git a/spec/features/projects/environments_pod_logs_spec.rb b/spec/features/projects/environments_pod_logs_spec.rb
index a51f121bf59..32eaf690950 100644
--- a/spec/features/projects/environments_pod_logs_spec.rb
+++ b/spec/features/projects/environments_pod_logs_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-describe 'Environment > Pod Logs', :js do
+describe 'Environment > Pod Logs', :js, :kubeclient do
include KubernetesHelpers
let(:pod_names) { %w(kube-pod) }
diff --git a/spec/features/projects/files/user_browses_files_spec.rb b/spec/features/projects/files/user_browses_files_spec.rb
index 5c52abaeb62..7e3d8e5c1c5 100644
--- a/spec/features/projects/files/user_browses_files_spec.rb
+++ b/spec/features/projects/files/user_browses_files_spec.rb
@@ -171,6 +171,18 @@ describe "User browses files" do
end
end
+ context 'when commit message has markdown', :js do
+ before do
+ project.repository.create_file(user, 'index', 'test', message: ':star: testing', branch_name: 'master')
+
+ visit(project_tree_path(project, "master"))
+ end
+
+ it 'renders emojis' do
+ expect(page).to have_selector('gl-emoji', count: 2)
+ end
+ end
+
context "when browsing a `improve/awesome` branch", :js do
before do
visit(project_tree_path(project, "improve/awesome"))
@@ -197,6 +209,33 @@ describe "User browses files" do
end
end
+ context "when browsing a `Ääh-test-utf-8` branch", :js do
+ before do
+ project.repository.create_branch('Ääh-test-utf-8', project.repository.root_ref)
+ visit(project_tree_path(project, "Ääh-test-utf-8"))
+ end
+
+ it "shows files from a repository" do
+ expect(page).to have_content("VERSION")
+ .and have_content(".gitignore")
+ .and have_content("LICENSE")
+
+ click_link("files")
+
+ page.within('.repo-breadcrumb') do
+ expect(page).to have_link('files')
+ end
+
+ click_link("html")
+
+ page.within('.repo-breadcrumb') do
+ expect(page).to have_link('html')
+ end
+
+ expect(page).to have_link('500.html')
+ end
+ end
+
context "when browsing a `test-#` branch", :js do
before do
project.repository.create_branch('test-#', project.repository.root_ref)
diff --git a/spec/features/projects/graph_spec.rb b/spec/features/projects/graph_spec.rb
index 6b2a9a6b852..25efb7b28a7 100644
--- a/spec/features/projects/graph_spec.rb
+++ b/spec/features/projects/graph_spec.rb
@@ -59,7 +59,7 @@ describe 'Project Graph', :js do
it 'HTML escapes branch name' do
expect(page.body).to include("Commit statistics for <strong>#{ERB::Util.html_escape(branch_name)}</strong>")
- expect(page.body).not_to include(branch_name)
+ expect(page.find('.dropdown-toggle-text')['innerHTML']).to eq(ERB::Util.html_escape(branch_name))
end
end
diff --git a/spec/features/projects/import_export/export_file_spec.rb b/spec/features/projects/import_export/export_file_spec.rb
index 7ee8f42e6ef..1d6d5ae1b4d 100644
--- a/spec/features/projects/import_export/export_file_spec.rb
+++ b/spec/features/projects/import_export/export_file_spec.rb
@@ -52,7 +52,7 @@ describe 'Import/Export - project export integration test', :js do
project_json_path = File.join(tmpdir, 'project.json')
expect(File).to exist(project_json_path)
- project_hash = JSON.parse(IO.read(project_json_path))
+ project_hash = Gitlab::Json.parse(IO.read(project_json_path))
sensitive_words.each do |sensitive_word|
found = find_sensitive_attributes(sensitive_word, project_hash)
@@ -78,7 +78,7 @@ describe 'Import/Export - project export integration test', :js do
expect(File).to exist(project_json_path)
relations = []
- relations << JSON.parse(IO.read(project_json_path))
+ relations << Gitlab::Json.parse(IO.read(project_json_path))
Dir.glob(File.join(tmpdir, 'tree/project', '*.ndjson')) do |rb_filename|
File.foreach(rb_filename) do |line|
json = ActiveSupport::JSON.decode(line)
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
new file mode 100644
index 00000000000..d9a72f2d5c5
--- /dev/null
+++ b/spec/features/projects/issues/design_management/user_paginates_designs_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'User paginates issue designs', :js do
+ include DesignManagementTestHelpers
+
+ let(:project) { create(:project_empty_repo, :public) }
+ let(:issue) { create(:issue, project: project) }
+
+ before do
+ 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
+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
new file mode 100644
index 00000000000..2238e86a47f
--- /dev/null
+++ b/spec/features/projects/issues/design_management/user_permissions_upload_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'User design permissions', :js do
+ include DesignManagementTestHelpers
+
+ let(:project) { create(:project_empty_repo, :public) }
+ let(:issue) { create(:issue, project: project) }
+
+ 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
+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
new file mode 100644
index 00000000000..d160ab95a65
--- /dev/null
+++ b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'User uploads new design', :js do
+ include DesignManagementTestHelpers
+
+ let_it_be(:project) { create(:project_empty_repo, :public) }
+ let_it_be(:user) { project.owner }
+ let_it_be(:issue) { create(:issue, project: project) }
+
+ before do
+ sign_in(user)
+ end
+
+ context "when the feature is available" do
+ before do
+ enable_design_management
+
+ visit project_issue_path(project, issue)
+
+ click_link 'Designs'
+
+ wait_for_requests
+ end
+
+ it 'uploads designs' do
+ attach_file(:design_file, logo_fixture, make_visible: true)
+
+ 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
+
+ attach_file(:design_file, gif_fixture, make_visible: true)
+
+ expect(page).to have_selector('.js-design-list-item', count: 2)
+ end
+ end
+
+ context 'when the feature is not available' do
+ before do
+ visit project_issue_path(project, issue)
+
+ click_link 'Designs'
+
+ wait_for_requests
+ end
+
+ it 'shows the message about requirements' do
+ expect(page).to have_content("To enable design management, you'll need to meet the requirements.")
+ end
+ end
+
+ def logo_fixture
+ Rails.root.join('spec', 'fixtures', 'dk.png')
+ end
+
+ def gif_fixture
+ Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
+ end
+end
diff --git a/spec/features/projects/issues/design_management/user_views_design_images_spec.rb b/spec/features/projects/issues/design_management/user_views_design_images_spec.rb
new file mode 100644
index 00000000000..3d0f4df55c4
--- /dev/null
+++ b/spec/features/projects/issues/design_management/user_views_design_images_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Users views raw design image files' do
+ include DesignManagementTestHelpers
+
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:design) { create(:design, :with_file, issue: issue, versions_count: 2) }
+ let(:newest_version) { design.versions.ordered.first }
+ let(:oldest_version) { design.versions.ordered.last }
+
+ before do
+ enable_design_management
+ end
+
+ it 'serves the latest design version when no ref is given' do
+ visit project_design_management_designs_raw_image_path(design.project, design)
+
+ expect(response_headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to eq(
+ workhorse_data_header_for_version(oldest_version.sha)
+ )
+ end
+
+ it 'serves the correct design version when a ref is given' do
+ visit project_design_management_designs_raw_image_path(design.project, design, oldest_version.sha)
+
+ expect(response_headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to eq(
+ workhorse_data_header_for_version(oldest_version.sha)
+ )
+ end
+
+ private
+
+ def workhorse_data_header_for_version(ref)
+ blob = project.design_repository.blob_at(ref, design.full_path)
+
+ Gitlab::Workhorse.send_git_blob(project.design_repository, blob).last
+ 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
new file mode 100644
index 00000000000..707049b0068
--- /dev/null
+++ b/spec/features/projects/issues/design_management/user_views_design_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'User views issue designs', :js do
+ include DesignManagementTestHelpers
+
+ let_it_be(:project) { create(:project_empty_repo, :public) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:design) { create(:design, :with_file, issue: issue) }
+
+ 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
+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
new file mode 100644
index 00000000000..a4fb7456922
--- /dev/null
+++ b/spec/features/projects/issues/design_management/user_views_designs_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'User views issue designs', :js do
+ include DesignManagementTestHelpers
+
+ let_it_be(:project) { create(:project_empty_repo, :public) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:design) { create(:design, :with_file, issue: issue) }
+
+ before do
+ enable_design_management
+ 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
+
+ it 'expands the sidebar' do
+ expect(page).to have_selector('.layout-page.right-sidebar-expanded')
+ end
+ 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
+ end
+end
diff --git a/spec/features/projects/issues/design_management/user_views_designs_with_svg_xss_spec.rb b/spec/features/projects/issues/design_management/user_views_designs_with_svg_xss_spec.rb
new file mode 100644
index 00000000000..a9e4aa899a7
--- /dev/null
+++ b/spec/features/projects/issues/design_management/user_views_designs_with_svg_xss_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'User views an SVG design that contains XSS', :js do
+ include DesignManagementTestHelpers
+
+ let(:project) { create(:project_empty_repo, :public) }
+ let(:issue) { create(:issue, project: project) }
+ let(:file) { Rails.root.join('spec', 'fixtures', 'logo_sample.svg') }
+ let(:design) { create(:design, :with_file, filename: 'xss.svg', file: file, issue: issue) }
+
+ before do
+ enable_design_management
+
+ visit designs_project_issue_path(
+ project,
+ issue,
+ { vueroute: design.filename }
+ )
+
+ wait_for_requests
+ end
+
+ it 'has XSS within the SVG file' do
+ file_content = File.read(file)
+
+ expect(file_content).to include("<script>alert('FAIL')</script>")
+ end
+
+ it 'displays the SVG' do
+ expect(page).to have_selector("img.design-img[alt='xss.svg']", count: 1, visible: false)
+ end
+
+ it 'does not execute the JavaScript within the SVG' do
+ # The expectation is that we can call the capybara `page.dismiss_prompt`
+ # method to close a JavaScript alert prompt without a `Capybara::ModalNotFound`
+ # being raised.
+ run_expectation = -> {
+ page.dismiss_prompt(wait: 1)
+ }
+
+ # With the page loaded, there should be no alert modal
+ expect(run_expectation).to raise_error(
+ Capybara::ModalNotFound,
+ 'Unable to find modal dialog'
+ )
+
+ # Perform a negative control test of the above expectation.
+ # With an alert modal displaying, the modal should be dismissable.
+ execute_script('alert(true)')
+
+ expect(run_expectation).not_to raise_error
+ end
+end
diff --git a/spec/features/projects/members/list_spec.rb b/spec/features/projects/members/list_spec.rb
index 84000ef73ce..f404699b2f6 100644
--- a/spec/features/projects/members/list_spec.rb
+++ b/spec/features/projects/members/list_spec.rb
@@ -34,7 +34,7 @@ describe 'Project members list' do
expect(second_row).to be_blank
end
- it 'update user acess level', :js do
+ it 'update user access level', :js do
project.add_developer(user2)
visit_members_page
@@ -86,6 +86,23 @@ describe 'Project members list' do
end
end
+ context 'project bots' do
+ let(:project_bot) { create(:user, :project_bot, name: 'project_bot') }
+
+ before do
+ project.add_maintainer(project_bot)
+ end
+
+ it 'does not show form used to change roles and "Expiration date" or the remove user button' do
+ project_member = project.project_members.find_by(user_id: project_bot.id)
+
+ visit_members_page
+
+ expect(page).not_to have_selector("#edit_project_member_#{project_member.id}")
+ expect(page).not_to have_selector("#project_member_#{project_member.id} .btn-remove")
+ end
+ end
+
def add_user(id, role)
page.within ".invite-users-form" do
select2(id, from: "#user_ids", multiple: true)
diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb
index 9dfdaf54a2f..1797ca8aa7d 100644
--- a/spec/features/projects/navbar_spec.rb
+++ b/spec/features/projects/navbar_spec.rb
@@ -12,6 +12,8 @@ describe 'Project navbar' do
let_it_be(:project) { create(:project, :repository) }
before do
+ stub_licensed_features(service_desk: false)
+
project.add_maintainer(user)
sign_in(user)
end
@@ -40,7 +42,7 @@ describe 'Project navbar' do
context 'when pages are available' do
before do
- allow(Gitlab.config.pages).to receive(:enabled).and_return(true)
+ stub_config(pages: { enabled: true })
insert_after_sub_nav_item(
_('Operations'),
@@ -53,4 +55,21 @@ describe 'Project navbar' do
it_behaves_like 'verified navigation bar'
end
+
+ context 'when container registry is available' do
+ before do
+ stub_config(registry: { enabled: true })
+
+ insert_after_nav_item(
+ _('Operations'),
+ new_nav_item: {
+ nav_item: _('Packages & Registries'),
+ nav_sub_items: [_('Container Registry')]
+ }
+ )
+ visit project_path(project)
+ end
+
+ it_behaves_like 'verified navigation bar'
+ end
end
diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb
index e8846b5b617..de81547887b 100644
--- a/spec/features/projects/pipelines/pipeline_spec.rb
+++ b/spec/features/projects/pipelines/pipeline_spec.rb
@@ -19,32 +19,32 @@ describe 'Pipeline', :js do
shared_context 'pipeline builds' do
let!(:build_passed) do
create(:ci_build, :success,
- pipeline: pipeline, stage: 'build', name: 'build')
+ pipeline: pipeline, stage: 'build', stage_idx: 0, name: 'build')
end
let!(:build_failed) do
create(:ci_build, :failed,
- pipeline: pipeline, stage: 'test', name: 'test')
+ pipeline: pipeline, stage: 'test', stage_idx: 1, name: 'test')
end
let!(:build_preparing) do
create(:ci_build, :preparing,
- pipeline: pipeline, stage: 'deploy', name: 'prepare')
+ pipeline: pipeline, stage: 'deploy', stage_idx: 2, name: 'prepare')
end
let!(:build_running) do
create(:ci_build, :running,
- pipeline: pipeline, stage: 'deploy', name: 'deploy')
+ pipeline: pipeline, stage: 'deploy', stage_idx: 3, name: 'deploy')
end
let!(:build_manual) do
create(:ci_build, :manual,
- pipeline: pipeline, stage: 'deploy', name: 'manual-build')
+ pipeline: pipeline, stage: 'deploy', stage_idx: 3, name: 'manual-build')
end
let!(:build_scheduled) do
create(:ci_build, :scheduled,
- pipeline: pipeline, stage: 'deploy', name: 'delayed-job')
+ pipeline: pipeline, stage: 'deploy', stage_idx: 3, name: 'delayed-job')
end
let!(:build_external) do
@@ -307,9 +307,12 @@ describe 'Pipeline', :js do
context 'when the pipeline has manual stage' do
before do
- create(:ci_build, :manual, pipeline: pipeline, stage: 'publish', name: 'CentOS')
- create(:ci_build, :manual, pipeline: pipeline, stage: 'publish', name: 'Debian')
- create(:ci_build, :manual, pipeline: pipeline, stage: 'publish', name: 'OpenSUDE')
+ create(:ci_build, :manual, pipeline: pipeline, stage_idx: 10, stage: 'publish', name: 'CentOS')
+ create(:ci_build, :manual, pipeline: pipeline, stage_idx: 10, stage: 'publish', name: 'Debian')
+ create(:ci_build, :manual, pipeline: pipeline, stage_idx: 10, stage: 'publish', name: 'OpenSUDE')
+
+ # force to update stages statuses
+ Ci::ProcessPipelineService.new(pipeline).execute
visit_pipeline
end
@@ -324,9 +327,10 @@ describe 'Pipeline', :js do
visit_pipeline
end
- it 'shows Pipeline, Jobs and Failed Jobs tabs with link' do
+ it 'shows Pipeline, Jobs, DAG and Failed Jobs tabs with link' do
expect(page).to have_link('Pipeline')
expect(page).to have_link('Jobs')
+ expect(page).to have_link('DAG')
expect(page).to have_link('Failed Jobs')
end
@@ -611,6 +615,20 @@ describe 'Pipeline', :js do
end
end
end
+
+ context 'when FF dag_pipeline_tab is disabled' do
+ before do
+ stub_feature_flags(dag_pipeline_tab: false)
+ visit_pipeline
+ end
+
+ it 'does not show DAG link' do
+ expect(page).to have_link('Pipeline')
+ expect(page).to have_link('Jobs')
+ expect(page).not_to have_link('DAG')
+ expect(page).to have_link('Failed Jobs')
+ end
+ end
end
context 'when user does not have access to read jobs' do
@@ -862,9 +880,10 @@ describe 'Pipeline', :js do
end
context 'page tabs' do
- it 'shows Pipeline and Jobs tabs with link' do
+ it 'shows Pipeline, Jobs and DAG tabs with link' do
expect(page).to have_link('Pipeline')
expect(page).to have_link('Jobs')
+ expect(page).to have_link('DAG')
end
it 'shows counter in Jobs tab' do
@@ -1054,6 +1073,37 @@ describe 'Pipeline', :js do
end
end
+ describe 'GET /:project/pipelines/:id/dag' do
+ include_context 'pipeline builds'
+
+ let(:project) { create(:project, :repository) }
+ let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
+
+ before do
+ visit dag_project_pipeline_path(project, pipeline)
+ end
+
+ it 'shows DAG tab pane as active' do
+ expect(page).to have_css('#js-tab-dag.active', visible: false)
+ end
+
+ context 'page tabs' do
+ it 'shows Pipeline, Jobs and DAG tabs with link' do
+ expect(page).to have_link('Pipeline')
+ expect(page).to have_link('Jobs')
+ expect(page).to have_link('DAG')
+ end
+
+ it 'shows counter in Jobs tab' do
+ expect(page.find('.js-builds-counter').text).to eq(pipeline.total_size.to_s)
+ end
+
+ it 'shows DAG tab as active' do
+ expect(page).to have_css('li.js-dag-tab-link .active')
+ end
+ end
+ end
+
context 'when user sees pipeline flags in a pipeline detail page' do
let(:project) { create(:project, :repository) }
diff --git a/spec/features/projects/serverless/functions_spec.rb b/spec/features/projects/serverless/functions_spec.rb
index e494a0e9626..4c89af29339 100644
--- a/spec/features/projects/serverless/functions_spec.rb
+++ b/spec/features/projects/serverless/functions_spec.rb
@@ -40,7 +40,7 @@ describe 'Functions', :js do
it_behaves_like "it's missing knative installation"
end
- context 'when the user has a cluster and knative installed and visits the serverless page' do
+ context 'when the user has a cluster and knative installed and visits the serverless page', :kubeclient do
let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
let(:service) { cluster.platform_kubernetes }
let(:environment) { create(:environment, project: project) }
diff --git a/spec/features/projects/services/disable_triggers_spec.rb b/spec/features/projects/services/disable_triggers_spec.rb
index 2785f74bee2..d07abb94208 100644
--- a/spec/features/projects/services/disable_triggers_spec.rb
+++ b/spec/features/projects/services/disable_triggers_spec.rb
@@ -3,16 +3,12 @@
require 'spec_helper'
describe 'Disable individual triggers' do
- let(:project) { create(:project) }
- let(:user) { project.owner }
+ include_context 'project service activation'
+
let(:checkbox_selector) { 'input[type=checkbox][id$=_events]' }
before do
- sign_in(user)
-
- visit(project_settings_integrations_path(project))
-
- click_link(service_name)
+ visit_project_integration(service_name)
end
context 'service has multiple supported events' do
diff --git a/spec/features/projects/services/prometheus_external_alerts_spec.rb b/spec/features/projects/services/prometheus_external_alerts_spec.rb
index e33b2d9a75e..1a706f20352 100644
--- a/spec/features/projects/services/prometheus_external_alerts_spec.rb
+++ b/spec/features/projects/services/prometheus_external_alerts_spec.rb
@@ -3,26 +3,18 @@
require 'spec_helper'
describe 'Prometheus external alerts', :js do
- let(:project) { create(:project) }
- let(:user) { create(:user) }
+ include_context 'project service activation'
let(:alerts_section_selector) { '.js-prometheus-alerts' }
let(:alerts_section) { page.find(alerts_section_selector) }
- before do
- sign_in(user)
- project.add_maintainer(user)
-
- visit_edit_service
- end
-
context 'with manual configuration' do
before do
create(:prometheus_service, project: project, api_url: 'http://prometheus.example.com', manual_configuration: '1', active: true)
end
it 'shows the Alerts section' do
- visit_edit_service
+ visit_project_integration('Prometheus')
expect(alerts_section).to have_content('Alerts')
expect(alerts_section).to have_content('Receive alerts from manually configured Prometheus servers.')
@@ -33,16 +25,10 @@ describe 'Prometheus external alerts', :js do
context 'with no configuration' do
it 'does not show the Alerts section' do
+ visit_project_integration('Prometheus')
wait_for_requests
expect(page).not_to have_css(alerts_section_selector)
end
end
-
- private
-
- def visit_edit_service
- visit(project_settings_integrations_path(project))
- click_link('Prometheus')
- 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 4f3fb6ac3bf..3c5005d0c0c 100644
--- a/spec/features/projects/services/user_activates_issue_tracker_spec.rb
+++ b/spec/features/projects/services/user_activates_issue_tracker_spec.rb
@@ -3,29 +3,17 @@
require 'spec_helper'
describe 'User activates issue tracker', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
+ include_context 'project service activation'
let(:url) { 'http://tracker.example.com' }
- def fill_short_form(disabled: false)
- find('input[name="service[active]"] + button').click if disabled
+ def fill_form(disable: false, skip_new_issue_url: false)
+ click_active_toggle if disable
fill_in 'service_project_url', with: url
fill_in 'service_issues_url', with: "#{url}/:id"
- end
-
- def fill_full_form(disabled: false)
- fill_short_form(disabled: disabled)
- fill_in 'service_new_issue_url', with: url
- end
-
- before do
- project.add_maintainer(user)
- sign_in(user)
-
- visit project_settings_integrations_path(project)
+ 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|
@@ -34,16 +22,10 @@ describe 'User activates issue tracker', :js do
before do
stub_request(:head, url).to_return(headers: { 'Content-Type' => 'application/json' })
- click_link(tracker)
-
- if skip_new_issue_url
- fill_short_form
- else
- fill_full_form
- end
+ visit_project_integration(tracker)
+ fill_form(skip_new_issue_url: skip_new_issue_url)
- click_button('Test settings and save changes')
- wait_for_requests
+ click_test_integration
end
it 'activates the service' do
@@ -62,22 +44,10 @@ describe 'User activates issue tracker', :js do
it 'activates the service' do
stub_request(:head, url).to_raise(Gitlab::HTTP::Error)
- click_link(tracker)
+ visit_project_integration(tracker)
+ fill_form(skip_new_issue_url: skip_new_issue_url)
- if skip_new_issue_url
- fill_short_form
- else
- fill_full_form
- end
-
- click_button('Test settings and save changes')
- wait_for_requests
-
- expect(find('.flash-container-page')).to have_content 'Test failed.'
- expect(find('.flash-container-page')).to have_content 'Save anyway'
-
- find('.flash-alert .flash-action').click
- wait_for_requests
+ click_test_then_save_integration
expect(page).to have_content("#{tracker} activated.")
expect(current_path).to eq(project_settings_integrations_path(project))
@@ -87,13 +57,8 @@ describe 'User activates issue tracker', :js do
describe 'user disables the service' do
before do
- click_link(tracker)
-
- if skip_new_issue_url
- fill_short_form(disabled: true)
- else
- fill_full_form(disabled: true)
- end
+ visit_project_integration(tracker)
+ fill_form(disable: true, skip_new_issue_url: skip_new_issue_url)
click_button('Save changes')
end
diff --git a/spec/features/projects/services/user_activates_jira_spec.rb b/spec/features/projects/services/user_activates_jira_spec.rb
index fb9628032b2..a14dbf9c14d 100644
--- a/spec/features/projects/services/user_activates_jira_spec.rb
+++ b/spec/features/projects/services/user_activates_jira_spec.rb
@@ -3,14 +3,13 @@
require 'spec_helper'
describe 'User activates Jira', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
+ include_context 'project service activation'
let(:url) { 'http://jira.example.com' }
let(:test_url) { 'http://jira.example.com/rest/api/2/serverInfo' }
- def fill_form(disabled: false)
- find('input[name="service[active]"] + button').click if disabled
+ def fill_form(disable: false)
+ click_active_toggle if disable
fill_in 'service_url', with: url
fill_in 'service_username', with: 'username'
@@ -18,23 +17,15 @@ describe 'User activates Jira', :js do
fill_in 'service_jira_issue_transition_id', with: '25'
end
- before do
- project.add_maintainer(user)
- sign_in(user)
-
- visit project_settings_integrations_path(project)
- end
-
describe 'user sets and activates Jira Service' do
context 'when Jira connection test succeeds' do
before do
server_info = { key: 'value' }.to_json
- WebMock.stub_request(:get, test_url).with(basic_auth: %w(username password)).to_return(body: server_info)
+ stub_request(:get, test_url).with(basic_auth: %w(username password)).to_return(body: server_info)
- click_link('Jira')
+ visit_project_integration('Jira')
fill_form
- click_button('Test settings and save changes')
- wait_for_requests
+ click_test_integration
end
it 'activates the Jira service' do
@@ -51,10 +42,10 @@ describe 'User activates Jira', :js do
context 'when Jira connection test fails' do
it 'shows errors when some required fields are not filled in' do
- click_link('Jira')
+ visit_project_integration('Jira')
fill_in 'service_password', with: 'password'
- click_button('Test settings and save changes')
+ click_test_integration
page.within('.service-settings') do
expect(page).to have_content('This field is required.')
@@ -62,19 +53,12 @@ describe 'User activates Jira', :js do
end
it 'activates the Jira service' do
- WebMock.stub_request(:get, test_url).with(basic_auth: %w(username password))
+ stub_request(:get, test_url).with(basic_auth: %w(username password))
.to_raise(JIRA::HTTPError.new(double(message: 'message')))
- click_link('Jira')
+ visit_project_integration('Jira')
fill_form
- click_button('Test settings and save changes')
- wait_for_requests
-
- expect(find('.flash-container-page')).to have_content 'Test failed. message'
- expect(find('.flash-container-page')).to have_content 'Save anyway'
-
- find('.flash-alert .flash-action').click
- wait_for_requests
+ click_test_then_save_integration
expect(page).to have_content('Jira activated.')
expect(current_path).to eq(project_settings_integrations_path(project))
@@ -84,8 +68,8 @@ describe 'User activates Jira', :js do
describe 'user disables the Jira Service' do
before do
- click_link('Jira')
- fill_form(disabled: true)
+ visit_project_integration('Jira')
+ fill_form(disable: true)
click_button('Save changes')
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 ac9cb00be84..c6825ee663a 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
@@ -3,158 +3,158 @@
require 'spec_helper'
describe 'Set up Mattermost slash commands', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
- let(:mattermost_enabled) { true }
-
- before do
- stub_mattermost_setting(enabled: mattermost_enabled)
- project.add_maintainer(user)
- sign_in(user)
- visit edit_project_service_path(project, :mattermost_slash_commands)
- end
-
describe 'user visits the mattermost slash command config page' do
- it 'shows a help message' do
- expect(page).to have_content("This service allows users to perform common")
+ include_context 'project service activation'
+
+ before do
+ stub_mattermost_setting(enabled: mattermost_enabled)
+ visit_project_integration('Mattermost slash commands')
end
- it 'shows a token placeholder' do
- token_placeholder = find_field('service_token')['placeholder']
+ context 'mattermost service is enabled' do
+ let(:mattermost_enabled) { true }
- expect(token_placeholder).to eq('XXxxXXxxXXxxXXxxXXxxXXxx')
- end
+ it 'shows a help message' do
+ expect(page).to have_content("This service allows users to perform common")
+ end
- it 'redirects to the integrations page after saving but not activating' do
- token = ('a'..'z').to_a.join
+ it 'shows a token placeholder' do
+ token_placeholder = find_field('service_token')['placeholder']
- fill_in 'service_token', with: token
- find('input[name="service[active]"] + button').click
- click_on 'Save changes'
+ expect(token_placeholder).to eq('XXxxXXxxXXxxXXxxXXxxXXxx')
+ end
- expect(current_path).to eq(project_settings_integrations_path(project))
- expect(page).to have_content('Mattermost slash commands settings saved, but not activated.')
- end
+ it 'redirects to the integrations page after saving but not activating' do
+ token = ('a'..'z').to_a.join
- it 'redirects to the integrations page after activating' do
- token = ('a'..'z').to_a.join
+ fill_in 'service_token', with: token
+ click_active_toggle
+ click_on 'Save changes'
- fill_in 'service_token', with: token
- click_on 'Save changes'
+ expect(current_path).to eq(project_settings_integrations_path(project))
+ expect(page).to have_content('Mattermost slash commands settings saved, but not activated.')
+ end
- expect(current_path).to eq(project_settings_integrations_path(project))
- expect(page).to have_content('Mattermost slash commands activated.')
- end
+ it 'redirects to the integrations page after activating' do
+ token = ('a'..'z').to_a.join
- it 'shows the add to mattermost button' do
- expect(page).to have_link('Add to Mattermost')
- end
+ fill_in 'service_token', with: token
+ click_on 'Save changes'
- it 'shows an explanation if user is a member of no teams' do
- stub_teams(count: 0)
+ expect(current_path).to eq(project_settings_integrations_path(project))
+ expect(page).to have_content('Mattermost slash commands activated.')
+ end
- click_link 'Add to Mattermost'
+ it 'shows the add to mattermost button' do
+ expect(page).to have_link('Add to Mattermost')
+ end
- expect(page).to have_content('You aren’t a member of any team on the Mattermost instance')
- expect(page).to have_link('join a team', href: "#{Gitlab.config.mattermost.host}/select_team")
- end
+ it 'shows an explanation if user is a member of no teams' do
+ stub_teams(count: 0)
- it 'shows an explanation if user is a member of 1 team' do
- stub_teams(count: 1)
+ click_link 'Add to Mattermost'
- click_link 'Add to Mattermost'
+ expect(page).to have_content('You aren’t a member of any team on the Mattermost instance')
+ expect(page).to have_link('join a team', href: "#{Gitlab.config.mattermost.host}/select_team")
+ end
- expect(page).to have_content('The team where the slash commands will be used in')
- expect(page).to have_content('This is the only available team that you are a member of.')
- end
+ it 'shows an explanation if user is a member of 1 team' do
+ stub_teams(count: 1)
- it 'shows a disabled prefilled select if user is a member of 1 team' do
- teams = stub_teams(count: 1)
+ click_link 'Add to Mattermost'
- click_link 'Add to Mattermost'
+ expect(page).to have_content('The team where the slash commands will be used in')
+ expect(page).to have_content('This is the only available team that you are a member of.')
+ end
- team_name = teams.first['display_name']
- select_element = find('#mattermost_team_id')
- selected_option = select_element.find('option[selected]')
+ it 'shows a disabled prefilled select if user is a member of 1 team' do
+ teams = stub_teams(count: 1)
- expect(select_element['disabled']).to eq("true")
- expect(selected_option).to have_content(team_name.to_s)
- end
+ click_link 'Add to Mattermost'
- it 'has a hidden input for the prefilled value if user is a member of 1 team' do
- teams = stub_teams(count: 1)
+ team_name = teams.first['display_name']
+ select_element = find('#mattermost_team_id')
+ selected_option = select_element.find('option[selected]')
- click_link 'Add to Mattermost'
+ expect(select_element['disabled']).to eq("true")
+ expect(selected_option).to have_content(team_name.to_s)
+ end
- expect(find('input#mattermost_team_id', visible: false).value).to eq(teams.first['id'])
- end
+ it 'has a hidden input for the prefilled value if user is a member of 1 team' do
+ teams = stub_teams(count: 1)
- it 'shows an explanation user is a member of multiple teams' do
- stub_teams(count: 2)
+ click_link 'Add to Mattermost'
- click_link 'Add to Mattermost'
+ expect(find('input#mattermost_team_id', visible: false).value).to eq(teams.first['id'])
+ end
- expect(page).to have_content('Select the team where the slash commands will be used in')
- expect(page).to have_content('The list shows all available teams that you are a member of.')
- end
+ it 'shows an explanation user is a member of multiple teams' do
+ stub_teams(count: 2)
- it 'shows a select with team options user is a member of multiple teams' do
- stub_teams(count: 2)
+ click_link 'Add to Mattermost'
- click_link 'Add to Mattermost'
+ expect(page).to have_content('Select the team where the slash commands will be used in')
+ expect(page).to have_content('The list shows all available teams that you are a member of.')
+ end
- select_element = find('#mattermost_team_id')
+ it 'shows a select with team options user is a member of multiple teams' do
+ stub_teams(count: 2)
- expect(select_element['disabled']).to be_falsey
- expect(select_element.all('option').count).to eq(3)
- end
+ click_link 'Add to Mattermost'
- it 'shows an error alert with the error message if there is an error requesting teams' do
- allow_any_instance_of(MattermostSlashCommandsService).to receive(:list_teams) { [[], 'test mattermost error message'] }
+ select_element = find('#mattermost_team_id')
- click_link 'Add to Mattermost'
+ expect(select_element['disabled']).to be_falsey
+ expect(select_element.all('option').count).to eq(3)
+ end
- expect(page).to have_selector('.alert')
- expect(page).to have_content('test mattermost error message')
- end
+ it 'shows an error alert with the error message if there is an error requesting teams' do
+ allow_any_instance_of(MattermostSlashCommandsService).to receive(:list_teams) { [[], 'test mattermost error message'] }
- it 'enables the submit button if the required fields are provided', :js do
- stub_teams(count: 1)
+ click_link 'Add to Mattermost'
- click_link 'Add to Mattermost'
+ expect(page).to have_selector('.alert')
+ expect(page).to have_content('test mattermost error message')
+ end
- expect(find('input[type="submit"]')['disabled']).not_to eq("true")
- end
+ it 'enables the submit button if the required fields are provided', :js do
+ stub_teams(count: 1)
- it 'disables the submit button if the required fields are not provided', :js do
- stub_teams(count: 1)
+ click_link 'Add to Mattermost'
- click_link 'Add to Mattermost'
+ expect(find('input[type="submit"]')['disabled']).not_to eq("true")
+ end
- fill_in('mattermost_trigger', with: '')
+ it 'disables the submit button if the required fields are not provided', :js do
+ stub_teams(count: 1)
- expect(find('input[type="submit"]')['disabled']).to eq("true")
- end
+ click_link 'Add to Mattermost'
- def stub_teams(count: 0)
- teams = create_teams(count)
+ fill_in('mattermost_trigger', with: '')
- allow_any_instance_of(MattermostSlashCommandsService).to receive(:list_teams) { [teams, nil] }
+ expect(find('input[type="submit"]')['disabled']).to eq("true")
+ end
- teams
- end
+ def stub_teams(count: 0)
+ teams = create_teams(count)
- def create_teams(count = 0)
- teams = []
+ allow_any_instance_of(MattermostSlashCommandsService).to receive(:list_teams) { [teams, nil] }
- count.times do |i|
- teams.push({ "id" => "x#{i}", "display_name" => "x#{i}-name" })
+ teams
end
- teams
+ def create_teams(count = 0)
+ teams = []
+
+ count.times do |i|
+ teams.push({ "id" => "x#{i}", "display_name" => "x#{i}-name" })
+ end
+
+ teams
+ end
end
- describe 'mattermost service is not enabled' do
+ context 'mattermost service is not enabled' do
let(:mattermost_enabled) { false }
it 'shows the correct trigger url' do
diff --git a/spec/features/projects/services/user_activates_slack_slash_command_spec.rb b/spec/features/projects/services/user_activates_slack_slash_command_spec.rb
index 4ce1acd9377..05f1a0c6b17 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
@@ -3,13 +3,10 @@
require 'spec_helper'
describe 'Slack slash commands' do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
+ include_context 'project service activation'
before do
- project.add_maintainer(user)
- sign_in(user)
- visit edit_project_service_path(project, :slack_slash_commands)
+ visit_project_integration('Slack slash commands')
end
it 'shows a token placeholder' do
@@ -24,7 +21,7 @@ describe 'Slack slash commands' do
it 'redirects to the integrations page after saving but not activating', :js do
fill_in 'service_token', with: 'token'
- find('input[name="service[active]"] + button').click
+ click_active_toggle
click_on 'Save'
expect(current_path).to eq(project_settings_integrations_path(project))
diff --git a/spec/features/projects/services/user_activates_youtrack_spec.rb b/spec/features/projects/services/user_activates_youtrack_spec.rb
deleted file mode 100644
index 26734766ff0..00000000000
--- a/spec/features/projects/services/user_activates_youtrack_spec.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe 'User activates issue tracker', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
-
- let(:url) { 'http://tracker.example.com' }
-
- def fill_form(disabled: false)
- find('input[name="service[active]"] + button').click if disabled
-
- fill_in 'service_project_url', with: url
- fill_in 'service_issues_url', with: "#{url}/:id"
- end
-
- before do
- project.add_maintainer(user)
- sign_in(user)
-
- visit project_settings_integrations_path(project)
- end
-
- shared_examples 'external issue tracker activation' do |tracker:|
- describe 'user sets and activates the Service' do
- context 'when the connection test succeeds' do
- before do
- stub_request(:head, url).to_return(headers: { 'Content-Type' => 'application/json' })
-
- click_link(tracker)
- fill_form
- click_button('Test settings and save changes')
- wait_for_requests
- end
-
- it 'activates the service' do
- expect(page).to have_content("#{tracker} activated.")
- expect(current_path).to eq(project_settings_integrations_path(project))
- end
-
- it 'shows the link in the menu' do
- page.within('.nav-sidebar') do
- expect(page).to have_link(tracker, href: url)
- end
- end
- end
-
- context 'when the connection test fails' do
- it 'activates the service' do
- stub_request(:head, url).to_raise(Gitlab::HTTP::Error)
-
- click_link(tracker)
- fill_form
- click_button('Test settings and save changes')
- wait_for_requests
-
- expect(find('.flash-container-page')).to have_content 'Test failed.'
- expect(find('.flash-container-page')).to have_content 'Save anyway'
-
- find('.flash-alert .flash-action').click
- wait_for_requests
-
- expect(page).to have_content("#{tracker} activated.")
- expect(current_path).to eq(project_settings_integrations_path(project))
- end
- end
- end
-
- describe 'user disables the service' do
- before do
- click_link(tracker)
- fill_form(disabled: true)
- click_button('Save changes')
- end
-
- it 'saves but does not activate the service' do
- expect(page).to have_content("#{tracker} settings saved, but not activated.")
- expect(current_path).to eq(project_settings_integrations_path(project))
- end
-
- it 'does not show the external tracker link in the menu' do
- page.within('.nav-sidebar') do
- expect(page).not_to have_link(tracker, href: url)
- end
- end
- end
- end
-
- it_behaves_like 'external issue tracker activation', tracker: 'YouTrack'
-end
diff --git a/spec/features/projects/settings/access_tokens_spec.rb b/spec/features/projects/settings/access_tokens_spec.rb
new file mode 100644
index 00000000000..9a8a8e38164
--- /dev/null
+++ b/spec/features/projects/settings/access_tokens_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Project > Settings > Access Tokens', :js do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:bot_user) { create(:user, :project_bot) }
+ let_it_be(:project) { create(:project) }
+
+ before_all do
+ project.add_maintainer(user)
+ end
+
+ before do
+ sign_in(user)
+ end
+
+ def create_project_access_token
+ project.add_maintainer(bot_user)
+
+ create(:personal_access_token, user: bot_user)
+ end
+
+ def active_project_access_tokens
+ find('.table.active-tokens')
+ end
+
+ def no_project_access_tokens_message
+ find('.settings-message')
+ end
+
+ def created_project_access_token
+ find('#created-personal-access-token').value
+ end
+
+ describe 'token creation' do
+ it 'allows creation of a project access token' do
+ name = 'My project access token'
+
+ visit project_settings_access_tokens_path(project)
+ fill_in 'Name', with: name
+
+ # Set date to 1st of next month
+ find_field('Expires at').click
+ find('.pika-next').click
+ click_on '1'
+
+ # Scopes
+ check 'api'
+ check 'read_api'
+
+ click_on 'Create project access token'
+
+ expect(active_project_access_tokens).to have_text(name)
+ expect(active_project_access_tokens).to have_text('In')
+ expect(active_project_access_tokens).to have_text('api')
+ expect(active_project_access_tokens).to have_text('read_api')
+ expect(created_project_access_token).not_to be_empty
+ end
+ end
+
+ describe 'active tokens' do
+ let!(:project_access_token) { create_project_access_token }
+
+ it 'shows active project access tokens' do
+ visit project_settings_access_tokens_path(project)
+
+ expect(active_project_access_tokens).to have_text(project_access_token.name)
+ end
+ end
+
+ describe 'inactive tokens' do
+ let!(:project_access_token) { create_project_access_token }
+
+ no_active_tokens_text = 'This project has no active access tokens.'
+
+ it 'allows revocation of an active token' do
+ visit project_settings_access_tokens_path(project)
+ accept_confirm { click_on 'Revoke' }
+
+ expect(page).to have_selector('.settings-message')
+ expect(no_project_access_tokens_message).to have_text(no_active_tokens_text)
+ end
+
+ it 'removes expired tokens from active section' do
+ project_access_token.update(expires_at: 5.days.ago)
+ visit project_settings_access_tokens_path(project)
+
+ expect(page).to have_selector('.settings-message')
+ expect(no_project_access_tokens_message).to have_text(no_active_tokens_text)
+ end
+ end
+end
diff --git a/spec/features/projects/settings/operations_settings_spec.rb b/spec/features/projects/settings/operations_settings_spec.rb
index 3c9102431e8..752353cf2f5 100644
--- a/spec/features/projects/settings/operations_settings_spec.rb
+++ b/spec/features/projects/settings/operations_settings_spec.rb
@@ -76,7 +76,7 @@ describe 'Projects > Settings > For a forked project', :js do
context 'success path' do
let(:projects_sample_response) do
Gitlab::Utils.deep_indifferent_access(
- JSON.parse(fixture_file('sentry/list_projects_sample_response.json'))
+ Gitlab::Json.parse(fixture_file('sentry/list_projects_sample_response.json'))
)
end
diff --git a/spec/features/projects/settings/project_settings_spec.rb b/spec/features/projects/settings/project_settings_spec.rb
index 9fc91550667..171c7920878 100644
--- a/spec/features/projects/settings/project_settings_spec.rb
+++ b/spec/features/projects/settings/project_settings_spec.rb
@@ -54,6 +54,36 @@ describe 'Projects settings' do
end
end
+ context 'default award emojis', :js do
+ it 'shows award emojis by default' do
+ visit edit_project_path(project)
+
+ default_award_emojis_input = find('input[name="project[project_setting_attributes][show_default_award_emojis]"]', visible: :hidden)
+
+ expect(default_award_emojis_input.value).to eq('true')
+ end
+
+ it 'disables award emojis when the checkbox is toggled off' do
+ visit edit_project_path(project)
+
+ default_award_emojis_input = find('input[name="project[project_setting_attributes][show_default_award_emojis]"]', visible: :hidden)
+ default_award_emojis_checkbox = find('input[name="project[project_setting_attributes][show_default_award_emojis]"][type=checkbox]')
+
+ expect(default_award_emojis_input.value).to eq('true')
+
+ default_award_emojis_checkbox.click
+
+ expect(default_award_emojis_input.value).to eq('false')
+
+ page.within('.sharing-permissions') do
+ find('input[value="Save changes"]').click
+ end
+ wait_for_requests
+
+ expect(default_award_emojis_input.value).to eq('false')
+ end
+ end
+
def expect_toggle_state(state)
is_collapsed = state == :collapsed
diff --git a/spec/features/projects/settings/registry_settings_spec.rb b/spec/features/projects/settings/registry_settings_spec.rb
index 74d3544ce92..ba92e8bc516 100644
--- a/spec/features/projects/settings/registry_settings_spec.rb
+++ b/spec/features/projects/settings/registry_settings_spec.rb
@@ -29,7 +29,7 @@ describe 'Project > Settings > CI/CD > Container registry tag expiration policy'
select('7 days until tags are automatically removed', from: 'Expiration interval:')
select('Every day', from: 'Expiration schedule:')
select('50 tags per image name', from: 'Number of tags to retain:')
- fill_in('Docker tags with names matching this regex pattern will expire:', with: '*-production')
+ fill_in('Tags with names matching this regex pattern will expire:', with: '*-production')
end
submit_button = find('.card-footer .btn.btn-success')
expect(submit_button).not_to be_disabled
diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb
index 2fb6c71384f..b8baaa3e963 100644
--- a/spec/features/projects/settings/repository_settings_spec.rb
+++ b/spec/features/projects/settings/repository_settings_spec.rb
@@ -30,7 +30,7 @@ describe 'Projects > Settings > Repository settings' do
before do
stub_container_registry_config(enabled: true)
- stub_feature_flags(ajax_new_deploy_token: { enabled: false, thing: project })
+ stub_feature_flags(ajax_new_deploy_token: project)
visit project_settings_repository_path(project)
end
@@ -222,20 +222,6 @@ describe 'Projects > Settings > Repository settings' do
end
end
- # Removal: https://gitlab.com/gitlab-org/gitlab/-/issues/208828
- context 'with the `keep_divergent_refs` feature flag disabled' do
- before do
- stub_feature_flags(keep_divergent_refs: { enabled: false, thing: project })
- end
-
- it 'hides the "Keep divergent refs" option' do
- visit project_settings_repository_path(project)
-
- expect(page).not_to have_selector('#keep_divergent_refs')
- expect(page).not_to have_text('Keep divergent refs')
- end
- end
-
context 'repository cleanup settings' do
let(:object_map_file) { Rails.root.join('spec', 'fixtures', 'bfg_object_map.txt') }
diff --git a/spec/features/projects/settings/user_interacts_with_deploy_keys_spec.rb b/spec/features/projects/settings/user_interacts_with_deploy_keys_spec.rb
index cd9299150b2..45a16fda2cb 100644
--- a/spec/features/projects/settings/user_interacts_with_deploy_keys_spec.rb
+++ b/spec/features/projects/settings/user_interacts_with_deploy_keys_spec.rb
@@ -88,18 +88,18 @@ describe "User interacts with deploy keys", :js do
end
it "adds new key" do
- DEPLOY_KEY_TITLE = attributes_for(:key)[:title]
- DEPLOY_KEY_BODY = attributes_for(:key)[:key]
+ deploy_key_title = attributes_for(:key)[:title]
+ deploy_key_body = attributes_for(:key)[:key]
- fill_in("deploy_key_title", with: DEPLOY_KEY_TITLE)
- fill_in("deploy_key_key", with: DEPLOY_KEY_BODY)
+ fill_in("deploy_key_title", with: deploy_key_title)
+ fill_in("deploy_key_key", with: deploy_key_body)
click_button("Add key")
expect(current_path).to eq(project_settings_repository_path(project))
page.within(".deploy-keys") do
- expect(page).to have_content(DEPLOY_KEY_TITLE)
+ expect(page).to have_content(deploy_key_title)
end
end
end
diff --git a/spec/features/projects/settings/user_sees_revoke_deploy_token_modal_spec.rb b/spec/features/projects/settings/user_sees_revoke_deploy_token_modal_spec.rb
index a77240c5c33..0abc4b41a2b 100644
--- a/spec/features/projects/settings/user_sees_revoke_deploy_token_modal_spec.rb
+++ b/spec/features/projects/settings/user_sees_revoke_deploy_token_modal_spec.rb
@@ -11,7 +11,7 @@ describe 'Repository Settings > User sees revoke deploy token modal', :js do
before do
project.add_role(user, role)
sign_in(user)
- stub_feature_flags(ajax_new_deploy_token: { enabled: false, thing: project })
+ stub_feature_flags(ajax_new_deploy_token: project)
visit(project_settings_repository_path(project))
click_link('Revoke')
end
diff --git a/spec/features/projects/snippets/create_snippet_spec.rb b/spec/features/projects/snippets/create_snippet_spec.rb
index d883a1fc39c..1e8f9fa0875 100644
--- a/spec/features/projects/snippets/create_snippet_spec.rb
+++ b/spec/features/projects/snippets/create_snippet_spec.rb
@@ -5,7 +5,6 @@ require 'spec_helper'
shared_examples_for 'snippet editor' do
before do
stub_feature_flags(snippets_edit_vue: false)
- stub_feature_flags(monaco_snippets: flag)
end
def description_field
@@ -20,7 +19,7 @@ shared_examples_for 'snippet editor' do
fill_in 'project_snippet_description', with: 'My Snippet **Description**'
page.within('.file-editor') do
- el = flag == true ? find('.inputarea') : find('.ace_text-input', visible: false)
+ el = find('.inputarea')
el.send_keys 'Hello World!'
end
end
@@ -100,7 +99,7 @@ shared_examples_for 'snippet editor' do
end
context 'when the git operation fails' do
- let(:error) { 'This is a git error' }
+ let(:error) { 'Error creating the snippet' }
before do
allow_next_instance_of(Snippets::CreateService) do |instance|
@@ -145,15 +144,5 @@ describe 'Projects > Snippets > Create Snippet', :js do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
- context 'when using Monaco' do
- it_behaves_like "snippet editor" do
- let(:flag) { true }
- end
- end
-
- context 'when using ACE' do
- it_behaves_like "snippet editor" do
- let(:flag) { false }
- end
- end
+ it_behaves_like "snippet editor"
end
diff --git a/spec/features/projects/snippets/user_updates_snippet_spec.rb b/spec/features/projects/snippets/user_updates_snippet_spec.rb
index cf501e55e23..d19fe9e8d38 100644
--- a/spec/features/projects/snippets/user_updates_snippet_spec.rb
+++ b/spec/features/projects/snippets/user_updates_snippet_spec.rb
@@ -7,12 +7,9 @@ describe 'Projects > Snippets > User updates a snippet', :js do
let_it_be(:project) { create(:project, namespace: user.namespace) }
let_it_be(:snippet, reload: true) { create(:project_snippet, :repository, project: project, author: user) }
- let(:version_snippet_enabled) { true }
-
before do
stub_feature_flags(snippets_vue: false)
stub_feature_flags(snippets_edit_vue: false)
- stub_feature_flags(version_snippets: version_snippet_enabled)
project.add_maintainer(user)
sign_in(user)
@@ -35,18 +32,6 @@ describe 'Projects > Snippets > User updates a snippet', :js do
end
end
- context 'when feature flag :version_snippets is disabled' do
- let(:version_snippet_enabled) { false }
-
- it 'displays the snippet file_name and content' do
- aggregate_failures do
- expect(page.find_field('project_snippet_file_name').value).to eq snippet.file_name
- expect(page.find('.file-content')).to have_content(snippet.content)
- expect(page.find('.snippet-file-content', visible: false).value).to eq snippet.content
- end
- end
- end
-
it 'updates a snippet' do
fill_in('project_snippet_title', with: 'Snippet new title')
click_button('Save')
@@ -57,16 +42,17 @@ describe 'Projects > Snippets > User updates a snippet', :js do
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)
+ allow(instance).to receive(:create_commit).and_raise(StandardError, 'Error Message')
end
fill_in('project_snippet_title', with: 'Snippet new title')
+ fill_in('project_snippet_file_name', with: 'new_file_name')
click_button('Save')
end
it 'renders edit page and displays the error' do
- expect(page.find('.flash-container span').text).to eq('Error updating the snippet')
+ expect(page.find('.flash-container span').text).to eq('Error updating the snippet - Error Message')
expect(page).to have_content('Edit Snippet')
end
end
diff --git a/spec/features/projects/user_sees_user_popover_spec.rb b/spec/features/projects/user_sees_user_popover_spec.rb
index fafb3773866..6197460776d 100644
--- a/spec/features/projects/user_sees_user_popover_spec.rb
+++ b/spec/features/projects/user_sees_user_popover_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
describe 'User sees user popover', :js do
+ include Spec::Support::Helpers::Features::NotesHelpers
+
let_it_be(:project) { create(:project, :repository) }
let(:user) { project.creator }
let(:merge_request) do
@@ -17,13 +19,13 @@ describe 'User sees user popover', :js do
subject { page }
describe 'hovering over a user link in a merge request' do
+ let(:popover_selector) { '.user-popover' }
+
before do
visit project_merge_request_path(project, merge_request)
end
it 'displays user popover' do
- popover_selector = '.user-popover'
-
find('.js-user-link').hover
expect(page).to have_css(popover_selector, visible: true)
@@ -32,5 +34,17 @@ describe 'User sees user popover', :js do
expect(page).to have_content(user.name)
end
end
+
+ it "displays user popover in system note" do
+ add_note("/assign @#{user.username}")
+
+ wait_for_requests
+
+ find('.system-note-message .js-user-link').hover
+
+ page.within(popover_selector) do
+ expect(page).to have_content(user.name)
+ end
+ end
end
end
diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb
index 7d18c0f7a14..bc567d4db42 100644
--- a/spec/features/projects/wiki/markdown_preview_spec.rb
+++ b/spec/features/projects/wiki/markdown_preview_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
describe 'Projects > Wiki > User previews markdown changes', :js do
let_it_be(:user) { create(:user) }
let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
- let(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: '[some link](other-page)' }) }
+ let(:wiki_page) { create(:wiki_page, wiki: project.wiki, title: 'home', content: '[some link](other-page)') }
let(:wiki_content) do
<<-HEREDOC
[regular link](regular)
diff --git a/spec/features/projects/wiki/shortcuts_spec.rb b/spec/features/projects/wiki/shortcuts_spec.rb
index 806d2f28bb9..c51af2526c9 100644
--- a/spec/features/projects/wiki/shortcuts_spec.rb
+++ b/spec/features/projects/wiki/shortcuts_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
describe 'Wiki shortcuts', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
- let(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: 'Home page' }) }
+ let(:wiki_page) { create(:wiki_page, wiki: project.wiki, title: 'home', content: 'Home page') }
before do
sign_in(user)
diff --git a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
index 67996cc3e5d..5678ebcb72a 100644
--- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
@@ -195,7 +195,7 @@ describe "User creates wiki page" do
context "when wiki is not empty", :js do
before do
- create(:wiki_page, wiki: wiki, attrs: { title: 'home', content: 'Home page' })
+ create(:wiki_page, wiki: wiki, title: 'home', content: 'Home page')
visit(project_wikis_path(project))
end
@@ -304,19 +304,20 @@ describe "User creates wiki page" do
describe 'sidebar feature' do
context 'when there are some existing pages' do
before do
- create(:wiki_page, wiki: wiki, attrs: { title: 'home', content: 'home' })
- create(:wiki_page, wiki: wiki, attrs: { title: 'another', content: 'another' })
+ create(:wiki_page, wiki: wiki, title: 'home', content: 'home')
+ create(:wiki_page, wiki: wiki, title: 'another', content: 'another')
end
it 'renders a default sidebar when there is no customized sidebar' do
visit(project_wikis_path(project))
expect(page).to have_content('another')
+ expect(page).not_to have_link('View All Pages')
end
context 'when there is a customized sidebar' do
before do
- create(:wiki_page, wiki: wiki, attrs: { title: '_sidebar', content: 'My customized sidebar' })
+ create(:wiki_page, wiki: wiki, title: '_sidebar', content: 'My customized sidebar')
end
it 'renders my customized sidebar instead of the default one' do
@@ -328,17 +329,31 @@ describe "User creates wiki page" do
end
end
- context 'when there are more than 15 existing pages' do
+ context 'when there are 15 existing pages' do
before do
- create(:wiki_page, wiki: wiki, attrs: { title: 'home', content: 'home' })
- (1..14).each { |i| create(:wiki_page, wiki: wiki, attrs: { title: "page-#{i}", content: "page #{i}" }) }
+ (1..5).each { |i| create(:wiki_page, wiki: wiki, title: "my page #{i}") }
+ (6..10).each { |i| create(:wiki_page, wiki: wiki, title: "parent/my page #{i}") }
+ (11..15).each { |i| create(:wiki_page, wiki: wiki, title: "grandparent/parent/my page #{i}") }
end
- it 'renders a default sidebar when there is no customized sidebar' do
+ it 'shows all pages in the sidebar' do
visit(project_wikis_path(project))
- expect(page).to have_content('View All Pages')
- expect(page).to have_content('page 1')
+ (1..15).each { |i| expect(page).to have_content("my page #{i}") }
+ expect(page).not_to have_link('View All Pages')
+ end
+
+ context 'when there are more than 15 existing pages' do
+ before do
+ create(:wiki_page, wiki: wiki, title: 'my page 16')
+ end
+
+ it 'shows the first 15 pages in the sidebar' do
+ visit(project_wikis_path(project))
+
+ expect(page).to have_text('my page', count: 15)
+ expect(page).to have_link('View All Pages')
+ end
end
end
end
diff --git a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb
index ab3d912dd15..6c6af1c41d2 100644
--- a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
describe 'Projects > Wiki > User views Git access wiki page' do
let(:user) { create(:user) }
let(:project) { create(:project, :wiki_repo, :public) }
- let(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: '[some link](other-page)' }) }
+ let(:wiki_page) { create(:wiki_page, wiki: project.wiki, title: 'home', content: '[some link](other-page)') }
before do
sign_in(user)
diff --git a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb
index 9d9c83331fb..55509ddfa10 100644
--- a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb
@@ -64,7 +64,7 @@ describe 'User updates wiki page' do
context 'when wiki is not empty' do
let(:project_wiki) { create(:project_wiki, project: project, user: project.creator) }
- let!(:wiki_page) { create(:wiki_page, wiki: project_wiki, attrs: { title: 'home', content: 'Home page' }) }
+ let!(:wiki_page) { create(:wiki_page, wiki: project_wiki, title: 'home', content: 'Home page') }
before do
visit(project_wikis_path(project))
@@ -168,7 +168,7 @@ describe 'User updates wiki page' do
let(:project_wiki) { create(:project_wiki, project: project, user: project.creator) }
let(:page_name) { 'page_name' }
let(:page_dir) { "foo/bar/#{page_name}" }
- let!(:wiki_page) { create(:wiki_page, wiki: project_wiki, attrs: { title: page_dir, content: 'Home page' }) }
+ let!(:wiki_page) { create(:wiki_page, wiki: project_wiki, title: page_dir, content: 'Home page') }
before do
visit(project_wiki_edit_path(project, wiki_page))
diff --git a/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb b/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb
index 471e80b27dc..cb425e8b704 100644
--- a/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb
+++ b/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb
@@ -21,7 +21,7 @@ describe 'Projects > Wiki > User views wiki in project page' do
context 'when wiki homepage contains a link' do
before do
- create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: '[some link](other-page)' })
+ create(:wiki_page, wiki: project.wiki, title: 'home', content: '[some link](other-page)')
end
it 'displays the correct URL for the link' do
diff --git a/spec/features/projects/wiki/user_views_wiki_page_spec.rb b/spec/features/projects/wiki/user_views_wiki_page_spec.rb
index 8a338756323..e379e7466db 100644
--- a/spec/features/projects/wiki/user_views_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_views_wiki_page_spec.rb
@@ -11,7 +11,7 @@ describe 'User views a wiki page' do
let(:wiki_page) do
create(:wiki_page,
wiki: project.wiki,
- attrs: { title: 'home', content: "Look at this [image](#{path})\n\n ![alt text](#{path})" })
+ title: 'home', content: "Look at this [image](#{path})\n\n ![alt text](#{path})")
end
before do
diff --git a/spec/features/projects/wiki/user_views_wiki_pages_spec.rb b/spec/features/projects/wiki/user_views_wiki_pages_spec.rb
index 6740df1d4ed..584b2a76143 100644
--- a/spec/features/projects/wiki/user_views_wiki_pages_spec.rb
+++ b/spec/features/projects/wiki/user_views_wiki_pages_spec.rb
@@ -9,13 +9,13 @@ describe 'User views wiki pages' do
let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
let!(:wiki_page1) do
- create(:wiki_page, wiki: project.wiki, attrs: { title: '3 home', content: '3' })
+ create(:wiki_page, wiki: project.wiki, title: '3 home', content: '3')
end
let!(:wiki_page2) do
- create(:wiki_page, wiki: project.wiki, attrs: { title: '1 home', content: '1' })
+ create(:wiki_page, wiki: project.wiki, title: '1 home', content: '1')
end
let!(:wiki_page3) do
- create(:wiki_page, wiki: project.wiki, attrs: { title: '2 home', content: '2' })
+ create(:wiki_page, wiki: project.wiki, title: '2 home', content: '2')
end
let(:pages) do
diff --git a/spec/features/projects/wiki/users_views_asciidoc_page_with_includes_spec.rb b/spec/features/projects/wiki/users_views_asciidoc_page_with_includes_spec.rb
index 08eea14c438..014b63fa154 100644
--- a/spec/features/projects/wiki/users_views_asciidoc_page_with_includes_spec.rb
+++ b/spec/features/projects/wiki/users_views_asciidoc_page_with_includes_spec.rb
@@ -16,7 +16,7 @@ describe 'User views AsciiDoc page with includes', :js do
format: :asciidoc
}
- create(:wiki_page, wiki: project.wiki, attrs: attrs)
+ create(:wiki_page, wiki: project.wiki, **attrs)
end
before do
diff --git a/spec/features/search/user_searches_for_code_spec.rb b/spec/features/search/user_searches_for_code_spec.rb
index 9949595fddf..0fdc7346535 100644
--- a/spec/features/search/user_searches_for_code_spec.rb
+++ b/spec/features/search/user_searches_for_code_spec.rb
@@ -40,6 +40,9 @@ describe 'User searches for code' do
find('.btn-search').click
expect(page).to have_selector('.results', text: 'Update capybara, rspec-rails, poltergeist to recent versions')
+
+ find("#L3").click
+ expect(current_url).to match(/master\/.gitignore#L3/)
end
it 'search mutiple words with refs switching' do
@@ -57,6 +60,7 @@ describe 'User searches for code' do
expect(page).to have_selector('.results', text: expected_result)
expect(find_field('dashboard_search').value).to eq(search)
+ expect(find("#L1502")[:href]).to match(/v1.0.0\/files\/markdown\/ruby-style-guide.md#L1502/)
end
end
diff --git a/spec/features/search/user_searches_for_wiki_pages_spec.rb b/spec/features/search/user_searches_for_wiki_pages_spec.rb
index 1ae37447bdc..10c3032da8b 100644
--- a/spec/features/search/user_searches_for_wiki_pages_spec.rb
+++ b/spec/features/search/user_searches_for_wiki_pages_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
describe 'User searches for wiki pages', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :repository, :wiki_repo, namespace: user.namespace) }
- let!(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: { title: 'directory/title', content: 'Some Wiki content' }) }
+ let!(:wiki_page) { create(:wiki_page, wiki: project.wiki, title: 'directory/title', content: 'Some Wiki content') }
before do
project.add_maintainer(user)
diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb
index 45b57b5cb1b..f29aa8de928 100644
--- a/spec/features/security/project/internal_access_spec.rb
+++ b/spec/features/security/project/internal_access_spec.rb
@@ -464,9 +464,9 @@ describe "Internal Project Access" do
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:maintainer).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
- it { is_expected.to be_denied_for(:reporter).of(project) }
- it { is_expected.to be_denied_for(:guest).of(project) }
- it { is_expected.to be_denied_for(:user) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_allowed_for(:guest).of(project) }
+ it { is_expected.to be_allowed_for(:user) }
it { is_expected.to be_denied_for(:external) }
it { is_expected.to be_denied_for(:visitor) }
end
diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb
index 9aeb3ffbd43..ac8596d89bc 100644
--- a/spec/features/security/project/private_access_spec.rb
+++ b/spec/features/security/project/private_access_spec.rb
@@ -499,7 +499,7 @@ describe "Private Project Access" do
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:maintainer).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
- it { is_expected.to be_denied_for(:reporter).of(project) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
it { is_expected.to be_denied_for(:guest).of(project) }
it { is_expected.to be_denied_for(:user) }
it { is_expected.to be_denied_for(:external) }
diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb
index 4d8c2c7822c..11e9bff10a1 100644
--- a/spec/features/security/project/public_access_spec.rb
+++ b/spec/features/security/project/public_access_spec.rb
@@ -278,11 +278,11 @@ describe "Public Project Access" do
it { is_expected.to be_allowed_for(:owner).of(project) }
it { is_expected.to be_allowed_for(:maintainer).of(project) }
it { is_expected.to be_allowed_for(:developer).of(project) }
- it { is_expected.to be_denied_for(:reporter).of(project) }
- it { is_expected.to be_denied_for(:guest).of(project) }
- it { is_expected.to be_denied_for(:user) }
- it { is_expected.to be_denied_for(:external) }
- it { is_expected.to be_denied_for(:visitor) }
+ it { is_expected.to be_allowed_for(:reporter).of(project) }
+ it { is_expected.to be_allowed_for(:guest).of(project) }
+ it { is_expected.to be_allowed_for(:user) }
+ it { is_expected.to be_allowed_for(:external) }
+ it { is_expected.to be_allowed_for(:visitor) }
end
describe "GET /:project_path/-/environments" do
diff --git a/spec/features/snippets/search_snippets_spec.rb b/spec/features/snippets/search_snippets_spec.rb
index 691716d3576..d3e02d43813 100644
--- a/spec/features/snippets/search_snippets_spec.rb
+++ b/spec/features/snippets/search_snippets_spec.rb
@@ -11,7 +11,7 @@ describe 'Search Snippets' do
visit dashboard_snippets_path
submit_search('Middle')
- select_search_scope('Titles and Filenames')
+ select_search_scope('Titles and Descriptions')
expect(page).to have_link(public_snippet.title)
expect(page).to have_link(private_snippet.title)
diff --git a/spec/features/snippets/spam_snippets_spec.rb b/spec/features/snippets/spam_snippets_spec.rb
index 69e3f190725..d7b181dc678 100644
--- a/spec/features/snippets/spam_snippets_spec.rb
+++ b/spec/features/snippets/spam_snippets_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
shared_examples_for 'snippet editor' do
+ include_context 'includes Spam constants'
+
def description_field
find('.js-description-input').find('input,textarea')
end
@@ -11,7 +13,6 @@ shared_examples_for 'snippet editor' do
stub_feature_flags(allow_possible_spam: false)
stub_feature_flags(snippets_vue: false)
stub_feature_flags(snippets_edit_vue: false)
- stub_feature_flags(monaco_snippets: flag)
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
Gitlab::CurrentSettings.update!(
@@ -33,18 +34,18 @@ shared_examples_for 'snippet editor' do
find('#personal_snippet_visibility_level_20').set(true)
page.within('.file-editor') do
- el = flag == true ? find('.inputarea') : find('.ace_text-input', visible: false)
+ el = find('.inputarea')
el.send_keys 'Hello World!'
end
end
- shared_examples 'solve recaptcha' do
- it 'creates a snippet after solving reCaptcha' do
+ shared_examples 'solve reCAPTCHA' do
+ it 'creates a snippet after solving reCAPTCHA' do
click_button('Create snippet')
wait_for_requests
- # it is impossible to test recaptcha automatically and there is no possibility to fill in recaptcha
- # recaptcha verification is skipped in test environment and it always returns true
+ # it is impossible to test reCAPTCHA automatically and there is no possibility to fill in recaptcha
+ # reCAPTCHA verification is skipped in test environment and it always returns true
expect(page).not_to have_content('My Snippet Title')
expect(page).to have_css('.recaptcha')
click_button('Submit personal snippet')
@@ -53,23 +54,62 @@ shared_examples_for 'snippet editor' do
end
end
- context 'when identified as spam' do
+ shared_examples 'does not allow creation' do
+ it 'rejects creation of the snippet' do
+ click_button('Create snippet')
+ wait_for_requests
+
+ expect(page).to have_content('discarded')
+ expect(page).not_to have_content('My Snippet Title')
+ expect(page).not_to have_css('.recaptcha')
+ end
+ end
+
+ context 'when SpamVerdictService requires recaptcha' do
+ before do
+ expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ expect(verdict_service).to receive(:execute).and_return(REQUIRE_RECAPTCHA)
+ end
+ end
+
+ context 'when allow_possible_spam feature flag is false' do
+ before do
+ stub_application_setting(recaptcha_enabled: false)
+ end
+
+ it_behaves_like 'does not allow creation'
+ end
+
+ context 'when allow_possible_spam feature flag is true' do
+ it_behaves_like 'solve reCAPTCHA'
+ end
+ end
+
+ context 'when SpamVerdictService disallows' do
before do
- WebMock.stub_request(:any, /.*akismet.com.*/).to_return(body: "true", status: 200)
+ expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ expect(verdict_service).to receive(:execute).and_return(DISALLOW)
+ end
end
context 'when allow_possible_spam feature flag is false' do
- it_behaves_like 'solve recaptcha'
+ before do
+ stub_application_setting(recaptcha_enabled: false)
+ end
+
+ it_behaves_like 'does not allow creation'
end
context 'when allow_possible_spam feature flag is true' do
- it_behaves_like 'solve recaptcha'
+ it_behaves_like 'does not allow creation'
end
end
- context 'when not identified as spam' do
+ context 'when SpamVerdictService allows' do
before do
- WebMock.stub_request(:any, /.*akismet.com.*/).to_return(body: "false", status: 200)
+ expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ expect(verdict_service).to receive(:execute).and_return(ALLOW)
+ end
end
it 'creates a snippet' do
@@ -85,15 +125,5 @@ end
describe 'User creates snippet', :js do
let_it_be(:user) { create(:user) }
- context 'when using Monaco' do
- it_behaves_like "snippet editor" do
- let(:flag) { true }
- end
- end
-
- context 'when using ACE' do
- it_behaves_like "snippet editor" do
- let(:flag) { false }
- end
- end
+ it_behaves_like "snippet editor"
end
diff --git a/spec/features/snippets/user_creates_snippet_spec.rb b/spec/features/snippets/user_creates_snippet_spec.rb
index 5d3a84dd7bc..62054c1f491 100644
--- a/spec/features/snippets/user_creates_snippet_spec.rb
+++ b/spec/features/snippets/user_creates_snippet_spec.rb
@@ -6,7 +6,6 @@ shared_examples_for 'snippet editor' do
before do
stub_feature_flags(snippets_vue: false)
stub_feature_flags(snippets_edit_vue: false)
- stub_feature_flags(monaco_snippets: flag)
sign_in(user)
visit new_snippet_path
end
@@ -23,7 +22,7 @@ shared_examples_for 'snippet editor' do
fill_in 'personal_snippet_description', with: 'My Snippet **Description**'
page.within('.file-editor') do
- el = flag == true ? find('.inputarea') : find('.ace_text-input', visible: false)
+ el = find('.inputarea')
el.send_keys 'Hello World!'
end
end
@@ -80,7 +79,7 @@ shared_examples_for 'snippet editor' do
end
context 'when the git operation fails' do
- let(:error) { 'This is a git error' }
+ let(:error) { 'Error creating the snippet' }
before do
allow_next_instance_of(Snippets::CreateService) do |instance|
@@ -136,7 +135,7 @@ shared_examples_for 'snippet editor' do
fill_in 'personal_snippet_title', with: 'My Snippet Title'
page.within('.file-editor') do
find(:xpath, "//input[@id='personal_snippet_file_name']").set 'snippet+file+name'
- el = flag == true ? find('.inputarea') : find('.ace_text-input', visible: false)
+ el = find('.inputarea')
el.send_keys 'Hello World!'
end
@@ -154,15 +153,5 @@ describe 'User creates snippet', :js do
let_it_be(:user) { create(:user) }
- context 'when using Monaco' do
- it_behaves_like "snippet editor" do
- let(:flag) { true }
- end
- end
-
- context 'when using ACE' do
- it_behaves_like "snippet editor" do
- let(:flag) { false }
- end
- end
+ it_behaves_like "snippet editor"
end
diff --git a/spec/features/snippets/user_edits_snippet_spec.rb b/spec/features/snippets/user_edits_snippet_spec.rb
index b4f8fbfa47e..40b0113cf39 100644
--- a/spec/features/snippets/user_edits_snippet_spec.rb
+++ b/spec/features/snippets/user_edits_snippet_spec.rb
@@ -10,12 +10,9 @@ describe 'User edits snippet', :js do
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) }
- let(:version_snippet_enabled) { true }
-
before do
stub_feature_flags(snippets_vue: false)
stub_feature_flags(snippets_edit_vue: false)
- stub_feature_flags(version_snippets: version_snippet_enabled)
sign_in(user)
@@ -33,18 +30,6 @@ describe 'User edits snippet', :js do
end
end
- context 'when feature flag :version_snippets is disabled' do
- let(:version_snippet_enabled) { false }
-
- it 'displays the snippet file_name and content' do
- aggregate_failures do
- expect(page.find_field('personal_snippet_file_name').value).to eq file_name
- expect(page.find('.file-content')).to have_content(content)
- expect(page.find('.snippet-file-content', visible: false).value).to eq content
- end
- end
- end
-
it 'updates the snippet' do
fill_in 'personal_snippet_title', with: 'New Snippet Title'
@@ -88,16 +73,17 @@ describe 'User edits snippet', :js do
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)
+ allow(instance).to receive(:create_commit).and_raise(StandardError, 'Error Message')
end
fill_in 'personal_snippet_title', with: 'New Snippet Title'
+ fill_in 'personal_snippet_file_name', with: 'new_file_name'
click_button('Save changes')
end
it 'renders edit page and displays the error' do
- expect(page.find('.flash-container span').text).to eq('Error updating the snippet')
+ expect(page.find('.flash-container span').text).to eq('Error updating the snippet - Error Message')
expect(page).to have_content('Edit Snippet')
end
end
diff --git a/spec/features/static_site_editor_spec.rb b/spec/features/static_site_editor_spec.rb
index c457002f888..de000ee2b9f 100644
--- a/spec/features/static_site_editor_spec.rb
+++ b/spec/features/static_site_editor_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
describe 'Static Site Editor' do
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :public, :repository) }
before do
project.add_maintainer(user)
diff --git a/spec/features/users/signup_spec.rb b/spec/features/users/signup_spec.rb
index daa987ea389..0ef86dde030 100644
--- a/spec/features/users/signup_spec.rb
+++ b/spec/features/users/signup_spec.rb
@@ -470,8 +470,8 @@ end
describe 'With experimental flow' do
before do
- stub_experiment(signup_flow: true, paid_signup_flow: false)
- stub_experiment_for_user(signup_flow: true, paid_signup_flow: false)
+ stub_experiment(signup_flow: true)
+ stub_experiment_for_user(signup_flow: true)
end
it_behaves_like 'Signup'
diff --git a/spec/finders/alert_management/alerts_finder_spec.rb b/spec/finders/alert_management/alerts_finder_spec.rb
new file mode 100644
index 00000000000..c6d2d0ad4ef
--- /dev/null
+++ b/spec/finders/alert_management/alerts_finder_spec.rb
@@ -0,0 +1,298 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe AlertManagement::AlertsFinder, '#execute' do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:alert_1) { create(:alert_management_alert, :all_fields, :resolved, project: project, ended_at: 1.year.ago, events: 2, severity: :high) }
+ let_it_be(:alert_2) { create(:alert_management_alert, :all_fields, :ignored, project: project, events: 1, severity: :critical) }
+ let_it_be(:alert_3) { create(:alert_management_alert, :all_fields) }
+ let(:params) { {} }
+
+ describe '#execute' do
+ subject { described_class.new(current_user, project, params).execute }
+
+ context 'user is not a developer or above' do
+ it { is_expected.to be_empty }
+ end
+
+ context 'user is developer' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ context 'empty params' do
+ it { is_expected.to contain_exactly(alert_1, alert_2) }
+ end
+
+ context 'iid given' do
+ let(:params) { { iid: alert_1.iid } }
+
+ it { is_expected.to match_array(alert_1) }
+
+ context 'unknown iid' do
+ let(:params) { { iid: 'unknown' } }
+
+ it { is_expected.to be_empty }
+ end
+ end
+
+ context 'status given' do
+ let(:params) { { status: AlertManagement::Alert::STATUSES[:resolved] } }
+
+ it { is_expected.to match_array(alert_1) }
+
+ context 'with an array of statuses' do
+ let(:alert_3) { create(:alert_management_alert) }
+ let(:params) { { status: [AlertManagement::Alert::STATUSES[:resolved]] } }
+
+ it { is_expected.to match_array(alert_1) }
+ end
+
+ context 'with no alerts of status' do
+ let(:params) { { status: AlertManagement::Alert::STATUSES[:acknowledged] } }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'with an empty status array' do
+ let(:params) { { status: [] } }
+
+ it { is_expected.to match_array([alert_1, alert_2]) }
+ end
+
+ context 'with an nil status' do
+ let(:params) { { status: nil } }
+
+ it { is_expected.to match_array([alert_1, alert_2]) }
+ end
+ end
+
+ describe 'sorting' do
+ context 'when sorting by created' do
+ context 'sorts alerts ascending' do
+ let(:params) { { sort: 'created_asc' } }
+
+ it { is_expected.to eq [alert_1, alert_2] }
+ end
+
+ context 'sorts alerts descending' do
+ let(:params) { { sort: 'created_desc' } }
+
+ it { is_expected.to eq [alert_2, alert_1] }
+ end
+ end
+
+ context 'when sorting by updated' do
+ context 'sorts alerts ascending' do
+ let(:params) { { sort: 'updated_asc' } }
+
+ it { is_expected.to eq [alert_1, alert_2] }
+ end
+
+ context 'sorts alerts descending' do
+ let(:params) { { sort: 'updated_desc' } }
+
+ it { is_expected.to eq [alert_2, alert_1] }
+ end
+ end
+
+ context 'when sorting by start time' do
+ context 'sorts alerts ascending' do
+ let(:params) { { sort: 'start_time_asc' } }
+
+ it { is_expected.to eq [alert_1, alert_2] }
+ end
+
+ context 'sorts alerts descending' do
+ let(:params) { { sort: 'start_time_desc' } }
+
+ it { is_expected.to eq [alert_2, alert_1] }
+ end
+ end
+
+ context 'when sorting by end time' do
+ context 'sorts alerts ascending' do
+ let(:params) { { sort: 'end_time_asc' } }
+
+ it { is_expected.to eq [alert_1, alert_2] }
+ end
+
+ context 'sorts alerts descending' do
+ let(:params) { { sort: 'end_time_desc' } }
+
+ it { is_expected.to eq [alert_2, alert_1] }
+ end
+ end
+
+ context 'when sorting by events count' do
+ let_it_be(:alert_count_6) { create(:alert_management_alert, project: project, events: 6) }
+ let_it_be(:alert_count_3) { create(:alert_management_alert, project: project, events: 3) }
+
+ context 'sorts alerts ascending' do
+ let(:params) { { sort: 'events_count_asc' } }
+
+ it { is_expected.to eq [alert_2, alert_1, alert_count_3, alert_count_6] }
+ end
+
+ context 'sorts alerts descending' do
+ let(:params) { { sort: 'events_count_desc' } }
+
+ it { is_expected.to eq [alert_count_6, alert_count_3, alert_1, alert_2] }
+ end
+ end
+
+ context 'when sorting by severity' do
+ let_it_be(:alert_critical) { create(:alert_management_alert, project: project, severity: :critical) }
+ let_it_be(:alert_high) { create(:alert_management_alert, project: project, severity: :high) }
+ let_it_be(:alert_medium) { create(:alert_management_alert, project: project, severity: :medium) }
+ let_it_be(:alert_low) { create(:alert_management_alert, project: project, severity: :low) }
+ let_it_be(:alert_info) { create(:alert_management_alert, project: project, severity: :info) }
+ let_it_be(:alert_unknown) { create(:alert_management_alert, project: project, severity: :unknown) }
+
+ context 'sorts alerts ascending' do
+ let(:params) { { sort: 'severity_asc' } }
+
+ it do
+ is_expected.to eq [
+ alert_2,
+ alert_critical,
+ alert_1,
+ alert_high,
+ alert_medium,
+ alert_low,
+ alert_info,
+ alert_unknown
+ ]
+ end
+ end
+
+ context 'sorts alerts descending' do
+ let(:params) { { sort: 'severity_desc' } }
+
+ it do
+ is_expected.to eq [
+ alert_unknown,
+ alert_info,
+ alert_low,
+ alert_medium,
+ alert_1,
+ alert_high,
+ alert_critical,
+ alert_2
+ ]
+ end
+ end
+ end
+
+ context 'when sorting by status' do
+ let_it_be(:alert_triggered) { create(:alert_management_alert, project: project) }
+ let_it_be(:alert_acknowledged) { create(:alert_management_alert, :acknowledged, project: project) }
+ let_it_be(:alert_resolved) { create(:alert_management_alert, :resolved, project: project) }
+ let_it_be(:alert_ignored) { create(:alert_management_alert, :ignored, project: project) }
+
+ context 'sorts alerts ascending' do
+ let(:params) { { sort: 'status_asc' } }
+
+ it do
+ is_expected.to eq [
+ alert_triggered,
+ alert_acknowledged,
+ alert_1,
+ alert_resolved,
+ alert_2,
+ alert_ignored
+ ]
+ end
+ end
+
+ context 'sorts alerts descending' do
+ let(:params) { { sort: 'status_desc' } }
+
+ it do
+ is_expected.to eq [
+ alert_2,
+ alert_ignored,
+ alert_1,
+ alert_resolved,
+ alert_acknowledged,
+ alert_triggered
+ ]
+ end
+ end
+ end
+ end
+ end
+
+ context 'search query given' do
+ let_it_be(:alert) do
+ create(:alert_management_alert,
+ :with_fingerprint,
+ title: 'Title',
+ description: 'Desc',
+ service: 'Service',
+ monitoring_tool: 'Monitor'
+ )
+ end
+
+ before do
+ alert.project.add_developer(current_user)
+ end
+
+ subject { described_class.new(current_user, alert.project, params).execute }
+
+ context 'searching title' do
+ let(:params) { { search: alert.title } }
+
+ it { is_expected.to match_array([alert]) }
+ end
+
+ context 'searching description' do
+ let(:params) { { search: alert.description } }
+
+ it { is_expected.to match_array([alert]) }
+ end
+
+ context 'searching service' do
+ let(:params) { { search: alert.service } }
+
+ it { is_expected.to match_array([alert]) }
+ end
+
+ context 'searching monitoring tool' do
+ let(:params) { { search: alert.monitoring_tool } }
+
+ it { is_expected.to match_array([alert]) }
+ end
+
+ context 'searching something else' do
+ let(:params) { { search: alert.fingerprint } }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'empty search' do
+ let(:params) { { search: ' ' } }
+
+ it { is_expected.to match_array([alert]) }
+ end
+ end
+ end
+
+ describe '.counts_by_status' do
+ subject { described_class.counts_by_status(current_user, project, params) }
+
+ before do
+ project.add_developer(current_user)
+ end
+
+ it { is_expected.to match({ 2 => 1, 3 => 1 }) } # one resolved and one ignored
+
+ context 'when filtering params are included' do
+ let(:params) { { status: AlertManagement::Alert::STATUSES[:resolved] } }
+
+ it { is_expected.to match({ 2 => 1 }) } # one resolved
+ end
+ end
+end
diff --git a/spec/finders/artifacts_finder_spec.rb b/spec/finders/artifacts_finder_spec.rb
deleted file mode 100644
index b956e2c9515..00000000000
--- a/spec/finders/artifacts_finder_spec.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe ArtifactsFinder do
- let(:project) { create(:project) }
-
- describe '#execute' do
- before do
- create(:ci_build, :artifacts, project: project)
- end
-
- subject { described_class.new(project, params).execute }
-
- context 'with empty params' do
- let(:params) { {} }
-
- it 'returns all artifacts belonging to the project' do
- expect(subject).to contain_exactly(*project.job_artifacts)
- end
- end
-
- context 'with sort param' do
- let(:params) { { sort: 'size_desc' } }
-
- it 'sorts the artifacts' do
- expect(subject).to eq(project.job_artifacts.order_by('size_desc'))
- end
- end
- end
-end
diff --git a/spec/finders/ci/daily_build_group_report_results_finder_spec.rb b/spec/finders/ci/daily_build_group_report_results_finder_spec.rb
new file mode 100644
index 00000000000..3000ef650d3
--- /dev/null
+++ b/spec/finders/ci/daily_build_group_report_results_finder_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::DailyBuildGroupReportResultsFinder do
+ describe '#execute' do
+ let(:project) { create(:project, :private) }
+ let(:ref_path) { 'refs/heads/master' }
+ let(:limit) { nil }
+
+ def create_daily_coverage(group_name, coverage, date)
+ create(
+ :ci_daily_build_group_report_result,
+ project: project,
+ ref_path: ref_path,
+ group_name: group_name,
+ data: { 'coverage' => coverage },
+ date: date
+ )
+ end
+
+ let!(:rspec_coverage_1) { create_daily_coverage('rspec', 79.0, '2020-03-09') }
+ let!(:karma_coverage_1) { create_daily_coverage('karma', 89.0, '2020-03-09') }
+ let!(:rspec_coverage_2) { create_daily_coverage('rspec', 95.0, '2020-03-10') }
+ let!(:karma_coverage_2) { create_daily_coverage('karma', 92.0, '2020-03-10') }
+ let!(:rspec_coverage_3) { create_daily_coverage('rspec', 97.0, '2020-03-11') }
+ let!(:karma_coverage_3) { create_daily_coverage('karma', 99.0, '2020-03-11') }
+
+ subject do
+ described_class.new(
+ current_user: current_user,
+ project: project,
+ ref_path: ref_path,
+ start_date: '2020-03-09',
+ end_date: '2020-03-10',
+ limit: limit
+ ).execute
+ end
+
+ context 'when current user is allowed to download project code' do
+ let(:current_user) { project.owner }
+
+ it 'returns all matching results within the given date range' do
+ expect(subject).to match_array([
+ karma_coverage_2,
+ rspec_coverage_2,
+ karma_coverage_1,
+ rspec_coverage_1
+ ])
+ end
+
+ context 'and limit is specified' do
+ let(:limit) { 2 }
+
+ it 'returns limited number of matching results within the given date range' do
+ expect(subject).to match_array([
+ karma_coverage_2,
+ rspec_coverage_2
+ ])
+ end
+ end
+ end
+
+ context 'when current user is not allowed to download project code' do
+ let(:current_user) { create(:user) }
+
+ it 'returns an empty result' do
+ expect(subject).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/finders/ci/job_artifacts_finder_spec.rb b/spec/finders/ci/job_artifacts_finder_spec.rb
new file mode 100644
index 00000000000..3e701ba87fa
--- /dev/null
+++ b/spec/finders/ci/job_artifacts_finder_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::JobArtifactsFinder do
+ let(:project) { create(:project) }
+
+ describe '#execute' do
+ before do
+ create(:ci_build, :artifacts, project: project)
+ end
+
+ subject { described_class.new(project, params).execute }
+
+ context 'with empty params' do
+ let(:params) { {} }
+
+ it 'returns all artifacts belonging to the project' do
+ expect(subject).to contain_exactly(*project.job_artifacts)
+ end
+ end
+
+ context 'with sort param' do
+ let(:params) { { sort: 'size_desc' } }
+
+ it 'sorts the artifacts' do
+ expect(subject).to eq(project.job_artifacts.order_by('size_desc'))
+ end
+ end
+ end
+end
diff --git a/spec/finders/container_repositories_finder_spec.rb b/spec/finders/container_repositories_finder_spec.rb
index 08c241186d6..d0c91a8f734 100644
--- a/spec/finders/container_repositories_finder_spec.rb
+++ b/spec/finders/container_repositories_finder_spec.rb
@@ -6,18 +6,35 @@ describe ContainerRepositoriesFinder do
let_it_be(:reporter) { create(:user) }
let_it_be(:guest) { create(:user) }
- let(:group) { create(:group) }
- let(:project) { create(:project, group: group) }
- let!(:project_repository) { create(:container_repository, project: project) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:project_repository) { create(:container_repository, name: 'my_image', project: project) }
+ let(:params) { {} }
before do
group.add_reporter(reporter)
project.add_reporter(reporter)
end
+ shared_examples 'with name search' do
+ let_it_be(:not_searched_repository) do
+ create(:container_repository, name: 'foo_bar_baz', project: project)
+ end
+
+ %w[my_image my_imag _image _imag].each do |name|
+ context "with name set to #{name}" do
+ let(:params) { { name: name } }
+
+ it { is_expected.to contain_exactly(project_repository)}
+
+ it { is_expected.not_to include(not_searched_repository)}
+ end
+ end
+ end
+
describe '#execute' do
context 'with authorized user' do
- subject { described_class.new(user: reporter, subject: subject_object).execute }
+ subject { described_class.new(user: reporter, subject: subject_object, params: params).execute }
context 'when subject_type is group' do
let(:subject_object) { group }
@@ -28,12 +45,16 @@ describe ContainerRepositoriesFinder do
end
it { is_expected.to match_array([project_repository, other_repository]) }
+
+ it_behaves_like 'with name search'
end
context 'when subject_type is project' do
let(:subject_object) { project }
it { is_expected.to match_array([project_repository]) }
+
+ it_behaves_like 'with name search'
end
context 'with invalid subject_type' do
diff --git a/spec/finders/design_management/designs_finder_spec.rb b/spec/finders/design_management/designs_finder_spec.rb
new file mode 100644
index 00000000000..04bd0ad0a45
--- /dev/null
+++ b/spec/finders/design_management/designs_finder_spec.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::DesignsFinder do
+ include DesignManagementTestHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:design1) { create(:design, :with_file, issue: issue, versions_count: 1) }
+ let_it_be(:design2) { create(:design, :with_file, issue: issue, versions_count: 1) }
+ let_it_be(:design3) { create(:design, :with_file, issue: issue, versions_count: 1) }
+ let(:params) { {} }
+
+ subject(:designs) { described_class.new(issue, user, params).execute }
+
+ describe '#execute' do
+ context 'when user can not read designs of an issue' do
+ it 'returns no results' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when user can read designs of an issue' do
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when design management feature is disabled' do
+ it 'returns no results' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when design management feature is enabled' do
+ before do
+ enable_design_management
+ end
+
+ it 'returns the designs' do
+ is_expected.to contain_exactly(design1, design2, design3)
+ end
+
+ context 'when argument is the ids of designs' do
+ let(:params) { { ids: [design1.id] } }
+
+ it { is_expected.to eq([design1]) }
+ end
+
+ context 'when argument is the filenames of designs' do
+ let(:params) { { filenames: [design2.filename] } }
+
+ it { is_expected.to eq([design2]) }
+ end
+
+ context 'when passed empty array' do
+ context 'for filenames' do
+ let(:params) { { filenames: [] } }
+
+ it { is_expected.to be_empty }
+ end
+
+ context "for ids" do
+ let(:params) { { ids: [] } }
+
+ it { is_expected.to be_empty }
+ end
+ end
+
+ describe 'returning designs that existed at a particular given version' do
+ let(:all_versions) { issue.design_collection.versions.ordered }
+ let(:first_version) { all_versions.last }
+ let(:second_version) { all_versions.second }
+
+ context 'when argument is the first version' do
+ let(:params) { { visible_at_version: first_version } }
+
+ it { is_expected.to eq([design1]) }
+ end
+
+ context 'when arguments are version and id' do
+ context 'when id is absent at version' do
+ let(:params) { { visible_at_version: first_version, ids: [design2.id] } }
+
+ it { is_expected.to eq([]) }
+ end
+
+ context 'when id is present at version' do
+ let(:params) { { visible_at_version: second_version, ids: [design2.id] } }
+
+ it { is_expected.to eq([design2]) }
+ end
+ end
+
+ context 'when argument is the second version' do
+ let(:params) { { visible_at_version: second_version } }
+
+ it { is_expected.to contain_exactly(design1, design2) }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/finders/design_management/versions_finder_spec.rb b/spec/finders/design_management/versions_finder_spec.rb
new file mode 100644
index 00000000000..11d53d0d630
--- /dev/null
+++ b/spec/finders/design_management/versions_finder_spec.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::VersionsFinder do
+ include DesignManagementTestHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:design_1) { create(:design, :with_file, issue: issue, versions_count: 1) }
+ let_it_be(:design_2) { create(:design, :with_file, issue: issue, versions_count: 1) }
+ let(:version_1) { design_1.versions.first }
+ let(:version_2) { design_2.versions.first }
+ let(:design_or_collection) { issue.design_collection }
+ let(:params) { {} }
+
+ let(:finder) { described_class.new(design_or_collection, user, params) }
+
+ subject(:versions) { finder.execute }
+
+ describe '#execute' do
+ shared_examples 'returns no results' do
+ it 'returns no results when passed a DesignCollection' do
+ expect(design_or_collection).is_a?(DesignManagement::DesignCollection)
+ is_expected.to be_empty
+ end
+
+ context 'when passed a Design' do
+ let(:design_or_collection) { design_1 }
+
+ it 'returns no results when passed a Design' do
+ is_expected.to be_empty
+ end
+ end
+ end
+
+ context 'when user cannot read designs of an issue' do
+ include_examples 'returns no results'
+ end
+
+ context 'when user can read designs of an issue' do
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when design management feature is disabled' do
+ include_examples 'returns no results'
+ end
+
+ context 'when design management feature is enabled' do
+ before do
+ enable_design_management
+ end
+
+ describe 'passing a DesignCollection or a Design for the initial scoping' do
+ it 'returns the versions scoped to the DesignCollection' do
+ expect(design_or_collection).is_a?(DesignManagement::DesignCollection)
+ is_expected.to eq(issue.design_collection.versions.ordered)
+ end
+
+ context 'when passed a Design' do
+ let(:design_or_collection) { design_1 }
+
+ it 'returns the versions scoped to the Design' do
+ is_expected.to eq(design_1.versions)
+ end
+ end
+ end
+
+ describe 'returning versions earlier or equal to a version' do
+ context 'when argument is the first version' do
+ let(:params) { { earlier_or_equal_to: version_1 }}
+
+ it { is_expected.to eq([version_1]) }
+ end
+
+ context 'when argument is the second version' do
+ let(:params) { { earlier_or_equal_to: version_2 }}
+
+ it { is_expected.to contain_exactly(version_1, version_2) }
+ end
+ end
+
+ describe 'returning versions by SHA' do
+ context 'when argument is the first version' do
+ let(:params) { { sha: version_1.sha } }
+
+ it { is_expected.to contain_exactly(version_1) }
+ end
+
+ context 'when argument is the second version' do
+ let(:params) { { sha: version_2.sha } }
+
+ it { is_expected.to contain_exactly(version_2) }
+ end
+ end
+
+ describe 'returning versions by ID' do
+ context 'when argument is the first version' do
+ let(:params) { { version_id: version_1.id } }
+
+ it { is_expected.to contain_exactly(version_1) }
+ end
+
+ context 'when argument is the second version' do
+ let(:params) { { version_id: version_2.id } }
+
+ it { is_expected.to contain_exactly(version_2) }
+ end
+ end
+
+ describe 'mixing id and sha' do
+ context 'when arguments are consistent' do
+ let(:params) { { version_id: version_1.id, sha: version_1.sha } }
+
+ it { is_expected.to contain_exactly(version_1) }
+ end
+
+ context 'when arguments are in-consistent' do
+ let(:params) { { version_id: version_1.id, sha: version_2.sha } }
+
+ it { is_expected.to be_empty }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/finders/fork_projects_finder_spec.rb b/spec/finders/fork_projects_finder_spec.rb
index 2fba53a74a0..02ce17ac907 100644
--- a/spec/finders/fork_projects_finder_spec.rb
+++ b/spec/finders/fork_projects_finder_spec.rb
@@ -14,7 +14,7 @@ describe ForkProjectsFinder do
let(:private_fork_member) { create(:user) }
before do
- stub_feature_flags(object_pools: { enabled: false, thing: source_project })
+ stub_feature_flags(object_pools: source_project)
private_fork.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
private_fork.add_developer(private_fork_member)
diff --git a/spec/finders/freeze_periods_finder_spec.rb b/spec/finders/freeze_periods_finder_spec.rb
new file mode 100644
index 00000000000..4ff356b85b7
--- /dev/null
+++ b/spec/finders/freeze_periods_finder_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe FreezePeriodsFinder do
+ subject(:finder) { described_class.new(project, user).execute }
+
+ let(:project) { create(:project, :private) }
+ let(:user) { create(:user) }
+ let!(:freeze_period_1) { create(:ci_freeze_period, project: project, created_at: 2.days.ago) }
+ let!(:freeze_period_2) { create(:ci_freeze_period, project: project, created_at: 1.day.ago) }
+
+ shared_examples_for 'returns nothing' do
+ specify do
+ is_expected.to be_empty
+ end
+ end
+
+ shared_examples_for 'returns freeze_periods ordered by created_at asc' do
+ it 'returns freeze_periods ordered by created_at' do
+ expect(subject.count).to eq(2)
+ expect(subject.pluck('id')).to eq([freeze_period_1.id, freeze_period_2.id])
+ end
+ end
+
+ context 'when user is a maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it_behaves_like 'returns freeze_periods ordered by created_at asc'
+ end
+
+ context 'when user is a guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it_behaves_like 'returns nothing'
+ end
+
+ context 'when user is a developer' do
+ before do
+ project.add_developer(user)
+ end
+
+ it_behaves_like 'returns nothing'
+ end
+
+ context 'when user is not a project member' do
+ it_behaves_like 'returns nothing'
+
+ context 'when project is public' do
+ let(:project) { create(:project, :public) }
+
+ it_behaves_like 'returns nothing'
+ end
+ end
+end
diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb
index baf40861a6e..7493fafb5cc 100644
--- a/spec/finders/issues_finder_spec.rb
+++ b/spec/finders/issues_finder_spec.rb
@@ -132,26 +132,6 @@ describe IssuesFinder do
end
end
- context 'filtering by NOT group_id' do
- let(:params) { { not: { group_id: group.id } } }
-
- context 'when include_subgroup param not set' do
- it 'returns all other group issues' do
- expect(issues).to contain_exactly(issue2, issue3, issue4)
- end
- end
-
- context 'when include_subgroup param is true', :nested_groups do
- before do
- params[:include_subgroups] = true
- end
-
- it 'returns all other group and subgroup issues' do
- expect(issues).to contain_exactly(issue2, issue3)
- end
- end
- end
-
context 'filtering by author ID' do
let(:params) { { author_id: user2.id } }
@@ -292,12 +272,12 @@ describe IssuesFinder do
context 'using NOT' do
let(:params) { { not: { milestone_title: Milestone::Upcoming.name } } }
- it 'returns issues not in upcoming milestones for each project or group' do
- target_issues = @created_issues.reject do |issue|
- issue.milestone&.due_date && issue.milestone.due_date > Date.current
- end + @created_issues.select { |issue| issue.milestone&.title == '8.9' }
+ it 'returns issues not in upcoming milestones for each project or group, but must have a due date' do
+ target_issues = @created_issues.select do |issue|
+ issue.milestone&.due_date && issue.milestone.due_date <= Date.current
+ end
- expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, *target_issues)
+ expect(issues).to contain_exactly(*target_issues)
end
end
end
@@ -343,9 +323,9 @@ describe IssuesFinder do
let(:params) { { not: { milestone_title: Milestone::Started.name } } }
it 'returns issues not in the started milestones for each project' do
- target_issues = Issue.where.not(milestone: Milestone.started)
+ target_issues = Issue.where(milestone: Milestone.not_started)
- expect(issues).to contain_exactly(issue2, issue3, issue4, *target_issues)
+ expect(issues).to contain_exactly(*target_issues)
end
end
end
@@ -452,14 +432,6 @@ describe IssuesFinder do
it 'returns issues with title and description match for search term' do
expect(issues).to contain_exactly(issue1, issue2)
end
-
- context 'using NOT' do
- let(:params) { { not: { search: 'git' } } }
-
- it 'returns issues with no title and description match for search term' do
- expect(issues).to contain_exactly(issue3, issue4)
- end
- end
end
context 'filtering by issue term in title' do
@@ -468,14 +440,6 @@ describe IssuesFinder do
it 'returns issues with title match for search term' do
expect(issues).to contain_exactly(issue1)
end
-
- context 'using NOT' do
- let(:params) { { not: { search: 'git', in: 'title' } } }
-
- it 'returns issues with no title match for search term' do
- expect(issues).to contain_exactly(issue2, issue3, issue4)
- end
- end
end
context 'filtering by issues iids' do
diff --git a/spec/finders/members_finder_spec.rb b/spec/finders/members_finder_spec.rb
index f6df727f7db..d77548c6fd0 100644
--- a/spec/finders/members_finder_spec.rb
+++ b/spec/finders/members_finder_spec.rb
@@ -110,7 +110,7 @@ describe MembersFinder, '#execute' do
project.add_maintainer(user3)
member3 = project.add_maintainer(user4)
- result = described_class.new(project, user2).execute(params: { search: user4.name })
+ result = described_class.new(project, user2, params: { search: user4.name }).execute
expect(result).to contain_exactly(member3)
end
@@ -120,7 +120,7 @@ describe MembersFinder, '#execute' do
member2 = project.add_maintainer(user3)
member3 = project.add_maintainer(user4)
- result = described_class.new(project, user2).execute(params: { sort: 'id_desc' })
+ result = described_class.new(project, user2, params: { sort: 'id_desc' }).execute
expect(result).to eq([member3, member2, member1])
end
diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb
index 42211f7ac9d..b6f2c7bb992 100644
--- a/spec/finders/merge_requests_finder_spec.rb
+++ b/spec/finders/merge_requests_finder_spec.rb
@@ -174,15 +174,16 @@ describe MergeRequestsFinder do
deployment1 = create(
:deployment,
project: project_with_repo,
- sha: project_with_repo.commit.id,
- merge_requests: [merge_request1, merge_request2]
+ sha: project_with_repo.commit.id
)
- create(
+ deployment2 = create(
:deployment,
project: project_with_repo,
- sha: project_with_repo.commit.id,
- merge_requests: [merge_request3]
+ sha: project_with_repo.commit.id
)
+ deployment1.link_merge_requests(MergeRequest.where(id: [merge_request1.id, merge_request2.id]))
+ deployment2.link_merge_requests(MergeRequest.where(id: merge_request3.id))
+
params = { deployment_id: deployment1.id }
merge_requests = described_class.new(user, params).execute
diff --git a/spec/finders/metrics/users_starred_dashboards_finder_spec.rb b/spec/finders/metrics/users_starred_dashboards_finder_spec.rb
new file mode 100644
index 00000000000..c32b8c2d335
--- /dev/null
+++ b/spec/finders/metrics/users_starred_dashboards_finder_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Metrics::UsersStarredDashboardsFinder do
+ describe '#execute' do
+ subject(:starred_dashboards) { described_class.new(user: user, project: project, params: params).execute }
+
+ let_it_be(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:dashboard_path) { 'config/prometheus/common_metrics.yml' }
+ let(:params) { {} }
+
+ context 'there are no starred dashboard records' do
+ it 'returns empty array' do
+ expect(starred_dashboards).to be_empty
+ end
+ end
+
+ context 'with annotation records' do
+ let!(:starred_dashboard_1) { create(:metrics_users_starred_dashboard, user: user, project: project) }
+ let!(:starred_dashboard_2) { create(:metrics_users_starred_dashboard, user: user, project: project, dashboard_path: dashboard_path) }
+ let!(:other_project_dashboard) { create(:metrics_users_starred_dashboard, user: user, dashboard_path: dashboard_path) }
+ let!(:other_user_dashboard) { create(:metrics_users_starred_dashboard, project: project, dashboard_path: dashboard_path) }
+
+ context 'user without read access to project' do
+ it 'returns empty relation' do
+ expect(starred_dashboards).to be_empty
+ end
+ end
+
+ context 'user with read access to project' do
+ before do
+ project.add_reporter(user)
+ end
+
+ it 'loads starred dashboards' do
+ expect(starred_dashboards).to contain_exactly starred_dashboard_1, starred_dashboard_2
+ end
+
+ context 'when the dashboard_path filter is present' do
+ let(:params) do
+ {
+ dashboard_path: dashboard_path
+ }
+ end
+
+ it 'loads filtered starred dashboards' do
+ expect(starred_dashboards).to contain_exactly starred_dashboard_2
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/finders/projects/serverless/functions_finder_spec.rb b/spec/finders/projects/serverless/functions_finder_spec.rb
index 4e9f3d371ce..1f0e3cd2eda 100644
--- a/spec/finders/projects/serverless/functions_finder_spec.rb
+++ b/spec/finders/projects/serverless/functions_finder_spec.rb
@@ -48,6 +48,7 @@ describe Projects::Serverless::FunctionsFinder do
expect(function_finder.knative_installed).to be false
end
end
+
context 'when project level cluster is present and enabled' do
it_behaves_like 'before first deployment' do
let(:cluster) { create(:cluster, :project, :provided_by_gcp, enabled: true) }
diff --git a/spec/finders/releases_finder_spec.rb b/spec/finders/releases_finder_spec.rb
index 3da5ee47b6b..cb4e5fed816 100644
--- a/spec/finders/releases_finder_spec.rb
+++ b/spec/finders/releases_finder_spec.rb
@@ -5,10 +5,11 @@ require 'spec_helper'
describe ReleasesFinder do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
+ let(:params) { {} }
let(:repository) { project.repository }
let(:v1_0_0) { create(:release, project: project, tag: 'v1.0.0') }
let(:v1_1_0) { create(:release, project: project, tag: 'v1.1.0') }
- let(:finder) { described_class.new(project, user) }
+ let(:finder) { described_class.new(project, user, params) }
before do
v1_0_0.update_attribute(:released_at, 2.days.ago)
@@ -64,6 +65,14 @@ describe ReleasesFinder do
expect(subject).to eq([v1_1_0])
end
end
+
+ context 'when a tag parameter is passed' do
+ let(:params) { { tag: 'v1.0.0' } }
+
+ it 'only returns the release with the matching tag' do
+ expect(subject).to eq([v1_0_0])
+ end
+ end
end
end
end
diff --git a/spec/finders/todos_finder_spec.rb b/spec/finders/todos_finder_spec.rb
index a35c3a954e7..87650835b05 100644
--- a/spec/finders/todos_finder_spec.rb
+++ b/spec/finders/todos_finder_spec.rb
@@ -260,9 +260,9 @@ describe TodosFinder do
it 'returns the expected types' do
expected_result =
if Gitlab.ee?
- %w[Epic Issue MergeRequest]
+ %w[Epic Issue MergeRequest DesignManagement::Design]
else
- %w[Issue MergeRequest]
+ %w[Issue MergeRequest DesignManagement::Design]
end
expect(described_class.todo_types).to contain_exactly(*expected_result)
diff --git a/spec/fixtures/accessibility/pa11y_with_errors.json b/spec/fixtures/accessibility/pa11y_with_errors.json
new file mode 100644
index 00000000000..35537f6cdd8
--- /dev/null
+++ b/spec/fixtures/accessibility/pa11y_with_errors.json
@@ -0,0 +1,109 @@
+{
+ "total": 1,
+ "passes": 0,
+ "errors": 10,
+ "results": {
+ "https://about.gitlab.com/": [
+ {
+ "code": "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent",
+ "type": "error",
+ "typeCode": 1,
+ "message": "Anchor element found with a valid href attribute, but no link content has been supplied.",
+ "context": "<a href=\"/\" class=\"navbar-brand animated\"><svg height=\"36\" viewBox=\"0 0 1...</a>",
+ "selector": "#main-nav > div:nth-child(1) > a",
+ "runner": "htmlcs",
+ "runnerExtras": {}
+ },
+ {
+ "code": "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
+ "type": "error",
+ "typeCode": 1,
+ "message": "This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 1.05:1. Recommendation: change background to #767676.",
+ "context": "<a href=\"/stages-devops-lifecycle/\" class=\"main-nav-link\">Product</a>",
+ "selector": "#main-nav > div:nth-child(2) > ul > li:nth-child(1) > a",
+ "runner": "htmlcs",
+ "runnerExtras": {}
+ },
+ {
+ "code": "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
+ "type": "error",
+ "typeCode": 1,
+ "message": "This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 1.05:1. Recommendation: change background to #767676.",
+ "context": "<a href=\"/pricing/\" class=\"main-nav-link\">Pricing</a>",
+ "selector": "#main-nav > div:nth-child(2) > ul > li:nth-child(2) > a",
+ "runner": "htmlcs",
+ "runnerExtras": {}
+ },
+ {
+ "code": "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
+ "type": "error",
+ "typeCode": 1,
+ "message": "This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 1.05:1. Recommendation: change background to #767676.",
+ "context": "<a href=\"/resources/\" class=\"main-nav-link\">Resources</a>",
+ "selector": "#main-nav > div:nth-child(2) > ul > li:nth-child(3) > a",
+ "runner": "htmlcs",
+ "runnerExtras": {}
+ },
+ {
+ "code": "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
+ "type": "error",
+ "typeCode": 1,
+ "message": "This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 1.05:1. Recommendation: change background to #767676.",
+ "context": "<a href=\"/blog/\" class=\"main-nav-link\">Blog</a>",
+ "selector": "#main-nav > div:nth-child(2) > ul > li:nth-child(4) > a",
+ "runner": "htmlcs",
+ "runnerExtras": {}
+ },
+ {
+ "code": "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
+ "type": "error",
+ "typeCode": 1,
+ "message": "This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 1.05:1. Recommendation: change background to #767676.",
+ "context": "<a href=\"/support/\" class=\"main-nav-link\">Support</a>",
+ "selector": "#main-nav > div:nth-child(2) > ul > li:nth-child(5) > a",
+ "runner": "htmlcs",
+ "runnerExtras": {}
+ },
+ {
+ "code": "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
+ "type": "error",
+ "typeCode": 1,
+ "message": "This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 1.05:1. Recommendation: change background to #767676.",
+ "context": "<a href=\"/jobs/\" class=\"main-nav-link\">Jobs</a>",
+ "selector": "#main-nav > div:nth-child(2) > ul > li:nth-child(6) > a",
+ "runner": "htmlcs",
+ "runnerExtras": {}
+ },
+ {
+ "code": "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
+ "type": "error",
+ "typeCode": 1,
+ "message": "This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 2.82:1. Recommendation: change background to #d1470c.",
+ "context": "<a class=\"btn btn-nav-cta btn-nav-link-cta\" href=\"/free-trial\">\nGet free trial\n</a>",
+ "selector": "#main-nav > div:nth-child(2) > ul > div:nth-child(8) > a",
+ "runner": "htmlcs",
+ "runnerExtras": {}
+ },
+ {
+ "code": "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
+ "type": "error",
+ "typeCode": 1,
+ "message": "This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 1.05:1. Recommendation: change background to #767676.",
+ "context": "<a class=\"main-nav-link sign-up\" href=\"https://gitlab.com/explore\" target=\"_blank\">\nExplore\n</a>",
+ "selector": "#main-nav > div:nth-child(2) > ul > div:nth-child(9) > li:nth-child(1) > a",
+ "runner": "htmlcs",
+ "runnerExtras": {}
+ },
+ {
+ "code": "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
+ "type": "error",
+ "typeCode": 1,
+ "message": "This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 1.05:1. Recommendation: change background to #767676.",
+ "context": "<a class=\"main-nav-link sign-up\" href=\"https://gitlab.com/users/sign_in\">\nSign in\n</a>",
+ "selector": "#main-nav > div:nth-child(2) > ul > div:nth-child(9) > li:nth-child(2) > a",
+ "runner": "htmlcs",
+ "runnerExtras": {}
+ }
+ ]
+ }
+}
diff --git a/spec/fixtures/accessibility/pa11y_with_invalid_url.json b/spec/fixtures/accessibility/pa11y_with_invalid_url.json
new file mode 100644
index 00000000000..aa4a663f944
--- /dev/null
+++ b/spec/fixtures/accessibility/pa11y_with_invalid_url.json
@@ -0,0 +1,12 @@
+{
+ "total": 1,
+ "passes": 0,
+ "errors": 0,
+ "results": {
+ "": [
+ {
+ "message": "Protocol error (Page.navigate): Cannot navigate to invalid URL"
+ }
+ ]
+ }
+}
diff --git a/spec/fixtures/accessibility/pa11y_without_errors.json b/spec/fixtures/accessibility/pa11y_without_errors.json
new file mode 100644
index 00000000000..c8720f6f052
--- /dev/null
+++ b/spec/fixtures/accessibility/pa11y_without_errors.json
@@ -0,0 +1,8 @@
+{
+ "total": 1,
+ "passes": 1,
+ "errors": 0,
+ "results": {
+ "https://pa11y.org/": []
+ }
+}
diff --git a/spec/fixtures/api/schemas/cluster_list.json b/spec/fixtures/api/schemas/cluster_list.json
new file mode 100644
index 00000000000..ece9542eb79
--- /dev/null
+++ b/spec/fixtures/api/schemas/cluster_list.json
@@ -0,0 +1,14 @@
+{
+ "clusters": {
+ "type": "array",
+ "items": {
+ "cluster_type": "string",
+ "enabled": "boolean",
+ "environment_scope": "string",
+ "name": "string",
+ "path": "string",
+ "status": "string"
+ }
+ },
+ "has_ancestor_clusters": { "type": ["boolean", "false"] }
+}
diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json
index ce62655648b..f6db336fe65 100644
--- a/spec/fixtures/api/schemas/cluster_status.json
+++ b/spec/fixtures/api/schemas/cluster_status.json
@@ -42,6 +42,8 @@
"host": {"type": ["string", "null"]},
"port": {"type": ["integer", "514"]},
"protocol": {"type": ["integer", "0"]},
+ "waf_log_enabled": {"type": ["boolean", "true"]},
+ "cilium_log_enabled": {"type": ["boolean", "true"]},
"update_available": { "type": ["boolean", "null"] },
"can_uninstall": { "type": "boolean" },
"available_domains": {
diff --git a/spec/fixtures/api/schemas/entities/accessibility_error.json b/spec/fixtures/api/schemas/entities/accessibility_error.json
new file mode 100644
index 00000000000..3ea84835505
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/accessibility_error.json
@@ -0,0 +1,40 @@
+{
+ "type": "object",
+ "required": [
+ "code",
+ "type",
+ "type_code",
+ "message",
+ "context",
+ "selector",
+ "runner",
+ "runner_extras"
+ ],
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ },
+ "type_code": {
+ "type": "integer"
+ },
+ "message": {
+ "type": "string"
+ },
+ "context": {
+ "type": "string"
+ },
+ "selector": {
+ "type": "string"
+ },
+ "runner": {
+ "type": "string"
+ },
+ "runner_extras": {
+ "type": ["object", "null"]
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/entities/accessibility_reports_comparer.json b/spec/fixtures/api/schemas/entities/accessibility_reports_comparer.json
new file mode 100644
index 00000000000..ec243354eab
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/accessibility_reports_comparer.json
@@ -0,0 +1,43 @@
+{
+ "type": "object",
+ "required": ["status", "summary", "new_errors", "resolved_errors", "existing_errors"],
+ "properties": {
+ "status": {
+ "type": "string"
+ },
+ "summary": {
+ "type": "object",
+ "properties": {
+ "total": {
+ "type": "integer"
+ },
+ "resolved": {
+ "type": "integer"
+ },
+ "errored": {
+ "type": "integer"
+ }
+ },
+ "required": ["total", "resolved", "errored"]
+ },
+ "new_errors": {
+ "type": "array",
+ "items": {
+ "$ref": "accessibility_error.json"
+ }
+ },
+ "resolved_errors": {
+ "type": "array",
+ "items": {
+ "$ref": "accessibility_error.json"
+ }
+ },
+ "existing_errors": {
+ "type": "array",
+ "items": {
+ "$ref": "accessibility_error.json"
+ }
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/entities/discussion.json b/spec/fixtures/api/schemas/entities/discussion.json
index 9d7ca62435e..21d8efe0b2b 100644
--- a/spec/fixtures/api/schemas/entities/discussion.json
+++ b/spec/fixtures/api/schemas/entities/discussion.json
@@ -29,8 +29,15 @@
"web_url": { "type": "uri" },
"status_tooltip_html": { "type": ["string", "null"] },
"path": { "type": "string" }
- },
- "additionalProperties": false
+ },
+ "required": [
+ "id",
+ "state",
+ "avatar_url",
+ "path",
+ "name",
+ "username"
+ ]
},
"created_at": { "type": "date" },
"updated_at": { "type": "date" },
diff --git a/spec/fixtures/api/schemas/entities/note_user_entity.json b/spec/fixtures/api/schemas/entities/note_user_entity.json
index 9b838054563..4a27d885cdc 100644
--- a/spec/fixtures/api/schemas/entities/note_user_entity.json
+++ b/spec/fixtures/api/schemas/entities/note_user_entity.json
@@ -16,6 +16,5 @@
"name": { "type": "string" },
"username": { "type": "string" },
"status_tooltip_html": { "$ref": "../types/nullable_string.json" }
- },
- "additionalProperties": false
+ }
}
diff --git a/spec/fixtures/api/schemas/entities/user.json b/spec/fixtures/api/schemas/entities/user.json
index 82d80b75cef..3252a37c82a 100644
--- a/spec/fixtures/api/schemas/entities/user.json
+++ b/spec/fixtures/api/schemas/entities/user.json
@@ -18,6 +18,5 @@
"name": { "type": "string" },
"username": { "type": "string" },
"status_tooltip_html": { "$ref": "../types/nullable_string.json" }
- },
- "additionalProperties": false
+ }
}
diff --git a/spec/fixtures/api/schemas/pipeline_schedule.json b/spec/fixtures/api/schemas/pipeline_schedule.json
index 690c4a7d4e8..d01801a15fa 100644
--- a/spec/fixtures/api/schemas/pipeline_schedule.json
+++ b/spec/fixtures/api/schemas/pipeline_schedule.json
@@ -30,7 +30,9 @@
"avatar_url": { "type": "uri" },
"web_url": { "type": "uri" }
},
- "additionalProperties": false
+ "required": [
+ "id", "name", "username", "state", "avatar_url", "web_url"
+ ]
},
"variables": {
"type": "array",
diff --git a/spec/fixtures/api/schemas/public_api/v4/branch.json b/spec/fixtures/api/schemas/public_api/v4/branch.json
index 3b0f010bc4f..0073a6d89fc 100644
--- a/spec/fixtures/api/schemas/public_api/v4/branch.json
+++ b/spec/fixtures/api/schemas/public_api/v4/branch.json
@@ -7,7 +7,8 @@
"protected",
"default",
"developers_can_push",
- "developers_can_merge"
+ "developers_can_merge",
+ "web_url"
],
"properties" : {
"name": { "type": "string" },
@@ -17,7 +18,8 @@
"default": { "type": "boolean" },
"developers_can_push": { "type": "boolean" },
"developers_can_merge": { "type": "boolean" },
- "can_push": { "type": "boolean" }
+ "can_push": { "type": "boolean" },
+ "web_url": { "type": "uri" }
},
"additionalProperties": false
}
diff --git a/spec/fixtures/api/schemas/public_api/v4/freeze_period.json b/spec/fixtures/api/schemas/public_api/v4/freeze_period.json
new file mode 100644
index 00000000000..b0187aee647
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/freeze_period.json
@@ -0,0 +1,20 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "freeze_start",
+ "freeze_end",
+ "cron_timezone",
+ "created_at",
+ "updated_at"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "freeze_start": { "type": "string" },
+ "freeze_end": { "type": "string"},
+ "cron_timezone": { "type": "string" },
+ "created_at": { "type": "string" },
+ "updated_at": { "type": "string" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/freeze_periods.json b/spec/fixtures/api/schemas/public_api/v4/freeze_periods.json
new file mode 100644
index 00000000000..1e1c29a3b64
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/freeze_periods.json
@@ -0,0 +1,5 @@
+{
+ "type": "array",
+ "items": { "$ref": "freeze_period.json" }
+}
+
diff --git a/spec/fixtures/api/schemas/public_api/v4/issue.json b/spec/fixtures/api/schemas/public_api/v4/issue.json
index bf1b4a06f0b..69ecba8b6f3 100644
--- a/spec/fixtures/api/schemas/public_api/v4/issue.json
+++ b/spec/fixtures/api/schemas/public_api/v4/issue.json
@@ -71,7 +71,14 @@
"avatar_url": { "type": "uri" },
"web_url": { "type": "uri" }
},
- "additionalProperties": false
+ "required": [
+ "id",
+ "state",
+ "avatar_url",
+ "name",
+ "username",
+ "web_url"
+ ]
},
"user_notes_count": { "type": "integer" },
"upvotes": { "type": "integer" },
diff --git a/spec/fixtures/api/schemas/public_api/v4/members.json b/spec/fixtures/api/schemas/public_api/v4/members.json
index 38ad64ad061..695f00b0040 100644
--- a/spec/fixtures/api/schemas/public_api/v4/members.json
+++ b/spec/fixtures/api/schemas/public_api/v4/members.json
@@ -15,8 +15,7 @@
},
"required": [
"id", "name", "username", "state",
- "web_url", "access_level", "expires_at"
- ],
- "additionalProperties": false
+ "web_url", "access_level", "expires_at", "avatar_url"
+ ]
}
}
diff --git a/spec/fixtures/api/schemas/public_api/v4/notes.json b/spec/fixtures/api/schemas/public_api/v4/notes.json
index d15d2e90b05..683dcb19836 100644
--- a/spec/fixtures/api/schemas/public_api/v4/notes.json
+++ b/spec/fixtures/api/schemas/public_api/v4/notes.json
@@ -17,7 +17,9 @@
"avatar_url": { "type": "uri" },
"web_url": { "type": "uri" }
},
- "additionalProperties": false
+ "required" : [
+ "id", "name", "username", "state", "avatar_url", "web_url"
+ ]
},
"commands_changes": { "type": "object", "additionalProperties": true },
"created_at": { "type": "date" },
diff --git a/spec/fixtures/api/schemas/public_api/v4/project_repository_storage_move.json b/spec/fixtures/api/schemas/public_api/v4/project_repository_storage_move.json
new file mode 100644
index 00000000000..6f8a2ff58e5
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/project_repository_storage_move.json
@@ -0,0 +1,20 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "created_at",
+ "state",
+ "source_storage_name",
+ "destination_storage_name",
+ "project"
+ ],
+ "properties" : {
+ "id": { "type": "integer" },
+ "created_at": { "type": "date" },
+ "state": { "type": "string" },
+ "source_storage_name": { "type": "string" },
+ "destination_storage_name": { "type": "string" },
+ "project": { "type": "object" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/project_repository_storage_moves.json b/spec/fixtures/api/schemas/public_api/v4/project_repository_storage_moves.json
new file mode 100644
index 00000000000..b2de185fbfe
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/project_repository_storage_moves.json
@@ -0,0 +1,6 @@
+{
+ "type": "array",
+ "items": {
+ "$ref": "./project_repository_storage_move.json"
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/snippets.json b/spec/fixtures/api/schemas/public_api/v4/snippets.json
index d13d703e063..7baa24a6f1f 100644
--- a/spec/fixtures/api/schemas/public_api/v4/snippets.json
+++ b/spec/fixtures/api/schemas/public_api/v4/snippets.json
@@ -10,6 +10,7 @@
"description": { "type": ["string", "null"] },
"visibility": { "type": "string" },
"web_url": { "type": "string" },
+ "raw_url": { "type": "string" },
"created_at": { "type": "date" },
"updated_at": { "type": "date" },
"author": {
@@ -22,12 +23,14 @@
"avatar_url": { "type": "uri" },
"web_url": { "type": "uri" }
},
- "additionalProperties": false
+ "required" : [
+ "id", "name", "username", "state", "avatar_url", "web_url"
+ ]
}
},
"required": [
"id", "title", "file_name", "description", "web_url",
- "created_at", "updated_at", "author"
+ "created_at", "updated_at", "author", "raw_url"
],
"additionalProperties": false
}
diff --git a/spec/fixtures/config/mail_room_enabled.yml b/spec/fixtures/config/mail_room_enabled.yml
index e1f4c2f44de..535528b32ca 100644
--- a/spec/fixtures/config/mail_room_enabled.yml
+++ b/spec/fixtures/config/mail_room_enabled.yml
@@ -9,6 +9,7 @@ test:
ssl: true
start_tls: false
mailbox: "inbox"
+ expunge_deleted: true
service_desk_email:
enabled: true
@@ -20,3 +21,4 @@ test:
ssl: true
start_tls: false
mailbox: "inbox"
+ expunge_deleted: true
diff --git a/spec/fixtures/config/redis_cache_new_format_host.yml b/spec/fixtures/config/redis_cache_new_format_host.yml
index a24f3716391..02b9e7384ac 100644
--- a/spec/fixtures/config/redis_cache_new_format_host.yml
+++ b/spec/fixtures/config/redis_cache_new_format_host.yml
@@ -7,7 +7,7 @@ development:
host: localhost
port: 26380 # point to sentinel, not to redis port
-
- host: slave2
+ host: replica2
port: 26380 # point to sentinel, not to redis port
test:
url: redis://:mynewpassword@localhost:6380/10
@@ -16,14 +16,14 @@ test:
host: localhost
port: 26380 # point to sentinel, not to redis port
-
- host: slave2
+ host: replica2
port: 26380 # point to sentinel, not to redis port
production:
url: redis://:mynewpassword@localhost:6380/10
sentinels:
-
- host: slave1
+ host: replica1
port: 26380 # point to sentinel, not to redis port
-
- host: slave2
+ host: replica2
port: 26380 # point to sentinel, not to redis port
diff --git a/spec/fixtures/config/redis_new_format_host.yml b/spec/fixtures/config/redis_new_format_host.yml
index 8d134d467e9..dc8d74c63fa 100644
--- a/spec/fixtures/config/redis_new_format_host.yml
+++ b/spec/fixtures/config/redis_new_format_host.yml
@@ -7,7 +7,7 @@ development:
host: localhost
port: 26379 # point to sentinel, not to redis port
-
- host: slave2
+ host: replica2
port: 26379 # point to sentinel, not to redis port
test:
url: redis://:mynewpassword@localhost:6379/99
@@ -16,14 +16,14 @@ test:
host: localhost
port: 26379 # point to sentinel, not to redis port
-
- host: slave2
+ host: replica2
port: 26379 # point to sentinel, not to redis port
production:
url: redis://:mynewpassword@localhost:6379/99
sentinels:
-
- host: slave1
+ host: replica1
port: 26379 # point to sentinel, not to redis port
-
- host: slave2
+ host: replica2
port: 26379 # point to sentinel, not to redis port
diff --git a/spec/fixtures/config/redis_queues_new_format_host.yml b/spec/fixtures/config/redis_queues_new_format_host.yml
index 1535584d779..bd0d82a5066 100644
--- a/spec/fixtures/config/redis_queues_new_format_host.yml
+++ b/spec/fixtures/config/redis_queues_new_format_host.yml
@@ -7,7 +7,7 @@ development:
host: localhost
port: 26381 # point to sentinel, not to redis port
-
- host: slave2
+ host: replica2
port: 26381 # point to sentinel, not to redis port
test:
url: redis://:mynewpassword@localhost:6381/11
@@ -16,14 +16,14 @@ test:
host: localhost
port: 26381 # point to sentinel, not to redis port
-
- host: slave2
+ host: replica2
port: 26381 # point to sentinel, not to redis port
production:
url: redis://:mynewpassword@localhost:6381/11
sentinels:
-
- host: slave1
+ host: replica1
port: 26381 # point to sentinel, not to redis port
-
- host: slave2
+ host: replica2
port: 26381 # point to sentinel, not to redis port
diff --git a/spec/fixtures/config/redis_shared_state_new_format_host.yml b/spec/fixtures/config/redis_shared_state_new_format_host.yml
index 1180b2b4a82..1c690567ae9 100644
--- a/spec/fixtures/config/redis_shared_state_new_format_host.yml
+++ b/spec/fixtures/config/redis_shared_state_new_format_host.yml
@@ -7,7 +7,7 @@ development:
host: localhost
port: 26382 # point to sentinel, not to redis port
-
- host: slave2
+ host: replica2
port: 26382 # point to sentinel, not to redis port
test:
url: redis://:mynewpassword@localhost:6382/12
@@ -16,14 +16,14 @@ test:
host: localhost
port: 26382 # point to sentinel, not to redis port
-
- host: slave2
+ host: replica2
port: 26382 # point to sentinel, not to redis port
production:
url: redis://:mynewpassword@localhost:6382/12
sentinels:
-
- host: slave1
+ host: replica1
port: 26382 # point to sentinel, not to redis port
-
- host: slave2
+ host: replica2
port: 26382 # point to sentinel, not to redis port
diff --git a/spec/fixtures/group_export.tar.gz b/spec/fixtures/group_export.tar.gz
index 5f5fd989f75..c8f3869ce51 100644
--- a/spec/fixtures/group_export.tar.gz
+++ b/spec/fixtures/group_export.tar.gz
Binary files differ
diff --git a/spec/fixtures/group_export_invalid_subrelations.tar.gz b/spec/fixtures/group_export_invalid_subrelations.tar.gz
index 6844d166260..e895e8ad9a2 100644
--- a/spec/fixtures/group_export_invalid_subrelations.tar.gz
+++ b/spec/fixtures/group_export_invalid_subrelations.tar.gz
Binary files differ
diff --git a/spec/fixtures/helm/helm_list_v2_prometheus_deployed.json.gz b/spec/fixtures/helm/helm_list_v2_prometheus_deployed.json.gz
new file mode 100644
index 00000000000..bcbbba8dc00
--- /dev/null
+++ b/spec/fixtures/helm/helm_list_v2_prometheus_deployed.json.gz
Binary files differ
diff --git a/spec/fixtures/helm/helm_list_v2_prometheus_failed.json.gz b/spec/fixtures/helm/helm_list_v2_prometheus_failed.json.gz
new file mode 100644
index 00000000000..0b39b42bdfa
--- /dev/null
+++ b/spec/fixtures/helm/helm_list_v2_prometheus_failed.json.gz
Binary files differ
diff --git a/spec/fixtures/helm/helm_list_v2_prometheus_missing.json.gz b/spec/fixtures/helm/helm_list_v2_prometheus_missing.json.gz
new file mode 100644
index 00000000000..20cac36287b
--- /dev/null
+++ b/spec/fixtures/helm/helm_list_v2_prometheus_missing.json.gz
Binary files differ
diff --git a/spec/fixtures/legacy_group_export.tar.gz b/spec/fixtures/legacy_group_export.tar.gz
new file mode 100644
index 00000000000..5f5fd989f75
--- /dev/null
+++ b/spec/fixtures/legacy_group_export.tar.gz
Binary files differ
diff --git a/spec/fixtures/legacy_group_export_invalid_subrelations.tar.gz b/spec/fixtures/legacy_group_export_invalid_subrelations.tar.gz
new file mode 100644
index 00000000000..6844d166260
--- /dev/null
+++ b/spec/fixtures/legacy_group_export_invalid_subrelations.tar.gz
Binary files differ
diff --git a/spec/fixtures/legacy_symlink_export.tar.gz b/spec/fixtures/legacy_symlink_export.tar.gz
new file mode 100644
index 00000000000..f295f69c56c
--- /dev/null
+++ b/spec/fixtures/legacy_symlink_export.tar.gz
Binary files differ
diff --git a/spec/fixtures/lib/elasticsearch/query.json b/spec/fixtures/lib/elasticsearch/query.json
index 75164a7439f..86431bac572 100644
--- a/spec/fixtures/lib/elasticsearch/query.json
+++ b/spec/fixtures/lib/elasticsearch/query.json
@@ -26,7 +26,7 @@
}
},
{
- "offset": {
+ "log.offset": {
"order": "desc"
}
}
diff --git a/spec/fixtures/lib/elasticsearch/query_with_container.json b/spec/fixtures/lib/elasticsearch/query_with_container.json
index 11bc653441c..3cbe2e814b1 100644
--- a/spec/fixtures/lib/elasticsearch/query_with_container.json
+++ b/spec/fixtures/lib/elasticsearch/query_with_container.json
@@ -33,7 +33,7 @@
}
},
{
- "offset": {
+ "log.offset": {
"order": "desc"
}
}
diff --git a/spec/fixtures/lib/elasticsearch/query_with_cursor.json b/spec/fixtures/lib/elasticsearch/query_with_cursor.json
index c5b81e97d3c..da697b0b081 100644
--- a/spec/fixtures/lib/elasticsearch/query_with_cursor.json
+++ b/spec/fixtures/lib/elasticsearch/query_with_cursor.json
@@ -26,7 +26,7 @@
}
},
{
- "offset": {
+ "log.offset": {
"order": "desc"
}
}
diff --git a/spec/fixtures/lib/elasticsearch/query_with_end_time.json b/spec/fixtures/lib/elasticsearch/query_with_end_time.json
index 226e0f115e7..dca08382cd8 100644
--- a/spec/fixtures/lib/elasticsearch/query_with_end_time.json
+++ b/spec/fixtures/lib/elasticsearch/query_with_end_time.json
@@ -35,7 +35,7 @@
}
},
{
- "offset": {
+ "log.offset": {
"order": "desc"
}
}
diff --git a/spec/fixtures/lib/elasticsearch/query_with_filebeat_6.json b/spec/fixtures/lib/elasticsearch/query_with_filebeat_6.json
new file mode 100644
index 00000000000..75164a7439f
--- /dev/null
+++ b/spec/fixtures/lib/elasticsearch/query_with_filebeat_6.json
@@ -0,0 +1,40 @@
+{
+ "query": {
+ "bool": {
+ "must": [
+ {
+ "match_phrase": {
+ "kubernetes.pod.name": {
+ "query": "production-6866bc8974-m4sk4"
+ }
+ }
+ },
+ {
+ "match_phrase": {
+ "kubernetes.namespace": {
+ "query": "autodevops-deploy-9-production"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "sort": [
+ {
+ "@timestamp": {
+ "order": "desc"
+ }
+ },
+ {
+ "offset": {
+ "order": "desc"
+ }
+ }
+ ],
+ "_source": [
+ "@timestamp",
+ "message",
+ "kubernetes.pod.name"
+ ],
+ "size": 500
+}
diff --git a/spec/fixtures/lib/elasticsearch/query_with_search.json b/spec/fixtures/lib/elasticsearch/query_with_search.json
index ca63c12f3b8..ab5c0ef13c2 100644
--- a/spec/fixtures/lib/elasticsearch/query_with_search.json
+++ b/spec/fixtures/lib/elasticsearch/query_with_search.json
@@ -35,7 +35,7 @@
}
},
{
- "offset": {
+ "log.offset": {
"order": "desc"
}
}
diff --git a/spec/fixtures/lib/elasticsearch/query_with_start_time.json b/spec/fixtures/lib/elasticsearch/query_with_start_time.json
index cb3e37de8a7..479e4b74cdf 100644
--- a/spec/fixtures/lib/elasticsearch/query_with_start_time.json
+++ b/spec/fixtures/lib/elasticsearch/query_with_start_time.json
@@ -35,7 +35,7 @@
}
},
{
- "offset": {
+ "log.offset": {
"order": "desc"
}
}
diff --git a/spec/fixtures/lib/elasticsearch/query_with_times.json b/spec/fixtures/lib/elasticsearch/query_with_times.json
index 91d28b28842..8bb0109a053 100644
--- a/spec/fixtures/lib/elasticsearch/query_with_times.json
+++ b/spec/fixtures/lib/elasticsearch/query_with_times.json
@@ -36,7 +36,7 @@
}
},
{
- "offset": {
+ "log.offset": {
"order": "desc"
}
}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/project.json b/spec/fixtures/lib/gitlab/import_export/complex/project.json
index 4bf72fe6912..0785da9c1bf 100644
--- a/spec/fixtures/lib/gitlab/import_export/complex/project.json
+++ b/spec/fixtures/lib/gitlab/import_export/complex/project.json
@@ -7394,26 +7394,6 @@
"category": "common",
"default": false,
"wiki_page_events": true
- },
- {
- "id": 101,
- "title": "JenkinsDeprecated",
- "project_id": 5,
- "created_at": "2016-06-14T15:01:51.031Z",
- "updated_at": "2016-06-14T15:01:51.031Z",
- "active": false,
- "properties": {},
- "template": false,
- "push_events": true,
- "issues_events": true,
- "merge_requests_events": true,
- "tag_push_events": true,
- "note_events": true,
- "job_events": true,
- "category": "common",
- "default": false,
- "wiki_page_events": true,
- "type": "JenkinsDeprecatedService"
}
],
"hooks": [],
@@ -7444,6 +7424,26 @@
]
}
],
+ "protected_environments": [
+ {
+ "id": 1,
+ "project_id": 9,
+ "created_at": "2017-10-19T15:36:23.466Z",
+ "updated_at": "2017-10-19T15:36:23.466Z",
+ "name": "production",
+ "deploy_access_levels": [
+ {
+ "id": 1,
+ "protected_environment_id": 1,
+ "created_at": "2017-10-19T15:36:23.466Z",
+ "updated_at": "2017-10-19T15:36:23.466Z",
+ "access_level": 40,
+ "user_id": 1,
+ "group_id": null
+ }
+ ]
+ }
+ ],
"protected_tags": [
{
"id": 1,
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree.tar.gz b/spec/fixtures/lib/gitlab/import_export/complex/tree.tar.gz
deleted file mode 100644
index feb1a70a89e..00000000000
--- a/spec/fixtures/lib/gitlab/import_export/complex/tree.tar.gz
+++ /dev/null
Binary files differ
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project.json b/spec/fixtures/lib/gitlab/import_export/complex/tree/project.json
new file mode 100644
index 00000000000..203b0264f9e
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project.json
@@ -0,0 +1 @@
+{"description":"Nisi et repellendus ut enim quo accusamus vel magnam.","import_type":"gitlab_project","creator_id":123,"visibility_level":10,"archived":false,"deploy_keys":[],"hooks":[]}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/auto_devops.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/auto_devops.ndjson
new file mode 100644
index 00000000000..85ae0843ce6
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/auto_devops.ndjson
@@ -0,0 +1 @@
+{"id":1,"created_at":"2017-10-19T15:36:23.466Z","updated_at":"2017-10-19T15:36:23.466Z","enabled":null,"deploy_strategy":"continuous"}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/boards.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/boards.ndjson
new file mode 100644
index 00000000000..ef18af69c9b
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/boards.ndjson
@@ -0,0 +1 @@
+{"id":29,"project_id":49,"created_at":"2019-06-06T14:01:06.204Z","updated_at":"2019-06-06T14:22:37.045Z","name":"TestBoardABC","milestone_id":null,"group_id":null,"weight":null,"lists":[{"id":59,"board_id":29,"label_id":null,"list_type":"backlog","position":null,"created_at":"2019-06-06T14:01:06.214Z","updated_at":"2019-06-06T14:01:06.214Z","user_id":null,"milestone_id":null},{"id":61,"board_id":29,"label_id":20,"list_type":"label","position":0,"created_at":"2019-06-06T14:01:43.197Z","updated_at":"2019-06-06T14:01:43.197Z","user_id":null,"milestone_id":null,"label":{"id":20,"title":"testlabel","color":"#0033CC","project_id":49,"created_at":"2019-06-06T14:01:19.698Z","updated_at":"2019-06-06T14:01:19.698Z","template":false,"description":null,"group_id":null,"type":"ProjectLabel","priorities":[]}},{"id":60,"board_id":29,"label_id":null,"list_type":"closed","position":null,"created_at":"2019-06-06T14:01:06.221Z","updated_at":"2019-06-06T14:01:06.221Z","user_id":null,"milestone_id":null}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/ci_cd_settings.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/ci_cd_settings.ndjson
new file mode 100644
index 00000000000..bf4165fe729
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/ci_cd_settings.ndjson
@@ -0,0 +1 @@
+{"group_runners_enabled":false}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/ci_pipelines.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/ci_pipelines.ndjson
new file mode 100644
index 00000000000..a9d04ec5d6d
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/ci_pipelines.ndjson
@@ -0,0 +1,7 @@
+{"id":19,"project_id":5,"ref":"master","sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.763Z","updated_at":"2016-03-22T15:20:35.763Z","tag":null,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"stages":[{"id":24,"project_id":5,"pipeline_id":40,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","statuses":[{"id":79,"project_id":5,"status":"failed","finished_at":"2016-03-29T06:28:12.695Z","trace":"Sed culpa est et facere saepe vel id ab. Quas temporibus aut similique dolorem consequatur corporis aut praesentium. Cum officia molestiae sit earum excepturi.\n\nSint possimus aut ratione quia. Quis nesciunt ratione itaque illo. Tenetur est dolor assumenda possimus voluptatem quia minima. Accusamus reprehenderit ut et itaque non reiciendis incidunt.\n\nRerum suscipit quibusdam dolore nam omnis. Consequatur ipsa nihil ut enim blanditiis delectus. Nulla quis hic occaecati mollitia qui placeat. Quo rerum sed perferendis a accusantium consequatur commodi ut. Sit quae et cumque vel eius tempora nostrum.\n\nUllam dolorem et itaque sint est. Ea molestias quia provident dolorem vitae error et et. Ea expedita officiis iste non. Qui vitae odit saepe illum. Dolores enim ratione deserunt tempore expedita amet non neque.\n\nEligendi asperiores voluptatibus omnis repudiandae expedita distinctio qui aliquid. Autem aut doloremque distinctio ab. Nostrum sapiente repudiandae aspernatur ea et quae voluptas. Officiis perspiciatis nisi laudantium asperiores error eligendi ab. Eius quia amet magni omnis exercitationem voluptatum et.\n\nVoluptatem ullam labore quas dicta est ex voluptas. Pariatur ea modi voluptas consequatur dolores perspiciatis similique. Numquam in distinctio perspiciatis ut qui earum. Quidem omnis mollitia facere aut beatae. Ea est iure et voluptatem.","created_at":"2016-03-22T15:20:35.950Z","updated_at":"2016-03-29T06:28:12.696Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":40,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null},{"id":80,"project_id":5,"status":"success","finished_at":null,"trace":"Impedit et optio nemo ipsa. Non ad non quis ut sequi laudantium omnis velit. Corporis a enim illo eos. Quia totam tempore inventore ad est.\n\nNihil recusandae cupiditate eaque voluptatem molestias sint. Consequatur id voluptatem cupiditate harum. Consequuntur iusto quaerat reiciendis aut autem libero est. Quisquam dolores veritatis rerum et sint maxime ullam libero. Id quas porro ut perspiciatis rem amet vitae.\n\nNemo inventore minus blanditiis magnam. Modi consequuntur nostrum aut voluptatem ex. Sunt rerum rem optio mollitia qui aliquam officiis officia. Aliquid eos et id aut minus beatae reiciendis.\n\nDolores non in temporibus dicta. Fugiat voluptatem est aspernatur expedita voluptatum nam qui. Quia et eligendi sit quae sint tempore exercitationem eos. Est sapiente corrupti quidem at. Qui magni odio repudiandae saepe tenetur optio dolore.\n\nEos placeat soluta at dolorem adipisci provident. Quo commodi id reprehenderit possimus quo tenetur. Ipsum et quae eligendi laborum. Et qui nesciunt at quasi quidem voluptatem cum rerum. Excepturi non facilis aut sunt vero sed.\n\nQui explicabo ratione ut eligendi recusandae. Quis quasi quas molestiae consequatur voluptatem et voluptatem. Ex repellat saepe occaecati aperiam ea eveniet dignissimos facilis.","created_at":"2016-03-22T15:20:35.966Z","updated_at":"2016-03-22T15:20:35.966Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":40,"commands":"$ build command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]}]}
+{"id":20,"project_id":5,"ref":"master","sha":"ce84140e8b878ce6e7c4d298c7202ff38170e3ac","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.763Z","updated_at":"2016-03-22T15:20:35.763Z","tag":false,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"stages":[],"source":"external_pull_request_event","external_pull_request":{"id":3,"pull_request_iid":4,"source_branch":"feature","target_branch":"master","source_repository":"the-repository","target_repository":"the-repository","source_sha":"ce84140e8b878ce6e7c4d298c7202ff38170e3ac","target_sha":"a09386439ca39abe575675ffd4b89ae824fec22f","status":"open","created_at":"2016-03-22T15:20:35.763Z","updated_at":"2016-03-22T15:20:35.763Z"}}
+{"id":26,"project_id":5,"ref":"master","sha":"048721d90c449b244b7b4c53a9186b04330174ec","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.757Z","updated_at":"2016-03-22T15:20:35.757Z","tag":false,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"source":"merge_request_event","merge_request_id":27,"stages":[{"id":21,"project_id":5,"pipeline_id":37,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","statuses":[{"id":74,"project_id":5,"status":"success","finished_at":null,"trace":"Ad ut quod repudiandae iste dolor doloribus. Adipisci consequuntur deserunt omnis quasi eveniet et sed fugit. Aut nemo omnis molestiae impedit ex consequatur ducimus. Voluptatum exercitationem quia aut est et hic dolorem.\n\nQuasi repellendus et eaque magni eum facilis. Dolorem aperiam nam nihil pariatur praesentium ad aliquam. Commodi enim et eos tenetur. Odio voluptatibus laboriosam mollitia rerum exercitationem magnam consequuntur. Tenetur ea vel eum corporis.\n\nVoluptatibus optio in aliquid est voluptates. Ad a ut ab placeat vero blanditiis. Earum aspernatur quia beatae expedita voluptatem dignissimos provident. Quis minima id nemo ut aut est veritatis provident.\n\nRerum voluptatem quidem eius maiores magnam veniam. Voluptatem aperiam aut voluptate et nulla deserunt voluptas. Quaerat aut accusantium laborum est dolorem architecto reiciendis. Aliquam asperiores doloribus omnis maxime enim nesciunt. Eum aut rerum repellendus debitis et ut eius.\n\nQuaerat assumenda ea sit consequatur autem in. Cum eligendi voluptatem quo sed. Ut fuga iusto cupiditate autem sint.\n\nOfficia totam officiis architecto corporis molestiae amet ut. Tempora sed dolorum rerum omnis voluptatem accusantium sit eum. Quia debitis ipsum quidem aliquam inventore sunt consequatur qui.","created_at":"2016-03-22T15:20:35.846Z","updated_at":"2016-03-22T15:20:35.846Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":37,"commands":"$ build command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null},{"id":73,"project_id":5,"status":"canceled","finished_at":null,"trace":null,"created_at":"2016-03-22T15:20:35.842Z","updated_at":"2016-03-22T15:20:35.842Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":37,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]}],"merge_request":{"id":27,"target_branch":"feature","source_branch":"feature_conflict","source_project_id":2147483547,"author_id":1,"assignee_id":null,"title":"MR1","created_at":"2016-06-14T15:02:36.568Z","updated_at":"2016-06-14T15:02:56.815Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":9,"description":null,"position":0,"updated_by_id":null,"merge_error":null,"diff_head_sha":"HEAD","source_branch_sha":"ABCD","target_branch_sha":"DCBA","merge_params":{"force_remove_source_branch":null}}}
+{"id":36,"project_id":5,"ref":null,"sha":"sha-notes","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.755Z","updated_at":"2016-03-22T15:20:35.755Z","tag":null,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"user_id":2147483547,"duration":null,"source":"push","merge_request_id":null,"notes":[{"id":2147483547,"note":"Natus rerum qui dolorem dolorum voluptas.","noteable_type":"Commit","author_id":1,"created_at":"2016-03-22T15:19:59.469Z","updated_at":"2016-03-22T15:19:59.469Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":"be93687618e4b132087f430a4d8fc3a609c9b77c","noteable_id":36,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"}}],"stages":[{"id":11,"project_id":5,"pipeline_id":36,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","statuses":[{"id":71,"project_id":5,"status":"failed","finished_at":"2016-03-29T06:28:12.630Z","trace":null,"created_at":"2016-03-22T15:20:35.772Z","updated_at":"2016-03-29T06:28:12.634Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":36,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":{"image":"busybox:latest"},"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"stage_id":11,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null,"type":"Ci::Build","token":"abcd","artifacts_file_store":1,"artifacts_metadata_store":1,"artifacts_size":10},{"id":72,"project_id":5,"status":"success","finished_at":null,"trace":"Porro ea qui ut dolores. Labore ab nemo explicabo aspernatur quis voluptates corporis. Et quasi delectus est sit aperiam perspiciatis asperiores. Repudiandae cum aut consectetur accusantium officia sunt.\n\nQuidem dolore iusto quaerat ut aut inventore et molestiae. Libero voluptates atque nemo qui. Nulla temporibus ipsa similique facere.\n\nAliquam ipsam perferendis qui fugit accusantium omnis id voluptatum. Dignissimos aliquid dicta eos voluptatem assumenda quia. Sed autem natus unde dolor et non nisi et. Consequuntur nihil consequatur rerum est.\n\nSimilique neque est iste ducimus qui fuga cupiditate. Libero autem est aut fuga. Consectetur natus quis non ducimus ut dolore. Magni voluptatibus eius et maxime aut.\n\nAd officiis tempore voluptate vitae corrupti explicabo labore est. Consequatur expedita et sunt nihil aut. Deleniti porro iusto molestiae et beatae.\n\nDeleniti modi nulla qui et labore sequi corrupti. Qui voluptatem assumenda eum cupiditate et. Nesciunt ipsam ut ea possimus eum. Consectetur quidem suscipit atque dolore itaque voluptatibus et cupiditate.","created_at":"2016-03-22T15:20:35.777Z","updated_at":"2016-03-22T15:20:35.777Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":36,"commands":"$ deploy command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"deploy","trigger_request_id":null,"stage_idx":1,"stage_id":12,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]},{"id":12,"project_id":5,"pipeline_id":36,"name":"deploy","status":2,"created_at":"2016-03-22T15:45:45.772Z","updated_at":"2016-03-29T06:45:45.634Z"}]}
+{"id":38,"iid":1,"project_id":5,"ref":"master","sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.759Z","updated_at":"2016-03-22T15:20:35.759Z","tag":null,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"stages":[{"id":22,"project_id":5,"pipeline_id":38,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","statuses":[{"id":76,"project_id":5,"status":"success","finished_at":null,"trace":"Et rerum quia ea cumque ut modi non. Libero eaque ipsam architecto maiores expedita deleniti. Ratione quia qui est id.\n\nQuod sit officiis sed unde inventore veniam quisquam velit. Ea harum cum quibusdam quisquam minima quo possimus non. Temporibus itaque aliquam aut rerum veritatis at.\n\nMagnam ipsum eius recusandae qui quis sit maiores eum. Et animi iusto aut itaque. Doloribus harum deleniti nobis accusantium et libero.\n\nRerum fuga perferendis magni commodi officiis id repudiandae. Consequatur ratione consequatur suscipit facilis sunt iure est dicta. Qui unde quasi facilis et quae nesciunt. Magnam iste et nobis officiis tenetur. Aspernatur quo et temporibus non in.\n\nNisi rerum velit est ad enim sint molestiae consequuntur. Quaerat nisi nesciunt quasi officiis. Possimus non blanditiis laborum quos.\n\nRerum laudantium facere animi qui. Ipsa est iusto magnam nihil. Enim omnis occaecati non dignissimos ut recusandae eum quasi. Qui maxime dolor et nemo voluptates incidunt quia.","created_at":"2016-03-22T15:20:35.882Z","updated_at":"2016-03-22T15:20:35.882Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":38,"commands":"$ build command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null},{"id":75,"project_id":5,"status":"failed","finished_at":null,"trace":"Sed et iste recusandae dicta corporis. Sunt alias porro fugit sunt. Fugiat omnis nihil dignissimos aperiam explicabo doloremque sit aut. Harum fugit expedita quia rerum ut consequatur laboriosam aliquam.\n\nNatus libero ut ut tenetur earum. Tempora omnis autem omnis et libero dolores illum autem. Deleniti eos sunt mollitia ipsam. Cum dolor repellendus dolorum sequi officia. Ullam sunt in aut pariatur excepturi.\n\nDolor nihil debitis et est eos. Cumque eos eum saepe ducimus autem. Alias architecto consequatur aut pariatur possimus. Aut quos aut incidunt quam velit et. Quas voluptatum ad dolorum dignissimos.\n\nUt voluptates consectetur illo et. Est commodi accusantium vel quo. Eos qui fugiat soluta porro.\n\nRatione possimus alias vel maxime sint totam est repellat. Ipsum corporis eos sint voluptatem eos odit. Temporibus libero nulla harum eligendi labore similique ratione magnam. Suscipit sequi in omnis neque.\n\nLaudantium dolor amet omnis placeat mollitia aut molestiae. Aut rerum similique ipsum quod illo quas unde. Sunt aut veritatis eos omnis porro. Rem veritatis mollitia praesentium dolorem. Consequatur sequi ad cumque earum omnis quia necessitatibus.","created_at":"2016-03-22T15:20:35.864Z","updated_at":"2016-03-22T15:20:35.864Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":38,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]}]}
+{"id":39,"project_id":5,"ref":"master","sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.761Z","updated_at":"2016-03-22T15:20:35.761Z","tag":null,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"stages":[{"id":23,"project_id":5,"pipeline_id":39,"name":"test","status":1,"created_at":"2016-03-22T15:44:44.772Z","updated_at":"2016-03-29T06:44:44.634Z","statuses":[{"id":78,"project_id":5,"status":"success","finished_at":null,"trace":"Dolorem deserunt quas quia error hic quo cum vel. Natus voluptatem cumque expedita numquam odit. Eos expedita nostrum corporis consequatur est recusandae.\n\nCulpa blanditiis rerum repudiandae alias voluptatem. Velit iusto est ullam consequatur doloribus porro. Corporis voluptas consectetur est veniam et quia quae.\n\nEt aut magni fuga nesciunt officiis molestias. Quaerat et nam necessitatibus qui rerum. Architecto quia officiis voluptatem laborum est recusandae. Quasi ducimus soluta odit necessitatibus labore numquam dignissimos. Quia facere sint temporibus inventore sunt nihil saepe dolorum.\n\nFacere dolores quis dolores a. Est minus nostrum nihil harum. Earum laborum et ipsum unde neque sit nemo. Corrupti est consequatur minima fugit. Illum voluptatem illo error ducimus officia qui debitis.\n\nDignissimos porro a autem harum aut. Aut id reprehenderit et exercitationem. Est et quisquam ipsa temporibus molestiae. Architecto natus dolore qui fugiat incidunt. Autem odit veniam excepturi et voluptatibus culpa ipsum eos.\n\nAmet quo quisquam dignissimos soluta modi dolores. Sint omnis eius optio corporis dolor. Eligendi animi porro quia placeat ut.","created_at":"2016-03-22T15:20:35.927Z","updated_at":"2016-03-22T15:20:35.927Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":39,"commands":"$ build command","job_id":null,"name":"test build 2","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null},{"id":77,"project_id":5,"status":"failed","finished_at":null,"trace":"Rerum ut et suscipit est perspiciatis. Inventore debitis cum eius vitae. Ex incidunt id velit aut quo nisi. Laboriosam repellat deserunt eius reiciendis architecto et. Est harum quos nesciunt nisi consectetur.\n\nAlias esse omnis sint officia est consequatur in nobis. Dignissimos dolorum vel eligendi nesciunt dolores sit. Veniam mollitia ducimus et exercitationem molestiae libero sed. Atque omnis debitis laudantium voluptatibus qui. Repellendus tempore est commodi pariatur.\n\nExpedita voluptate illum est alias non. Modi nesciunt ab assumenda laborum nulla consequatur molestias doloremque. Magnam quod officia vel explicabo accusamus ut voluptatem incidunt. Rerum ut aliquid ullam saepe. Est eligendi debitis beatae blanditiis reiciendis.\n\nQui fuga sit dolores libero maiores et suscipit. Consectetur asperiores omnis minima impedit eos fugiat. Similique omnis nisi sed vero inventore ipsum aliquam exercitationem.\n\nBlanditiis magni iure dolorum omnis ratione delectus molestiae. Atque officia dolor voluptatem culpa quod. Incidunt suscipit quidem possimus veritatis non vel. Iusto aliquid et id quia quasi.\n\nVel facere velit blanditiis incidunt cupiditate sed maiores consequuntur. Quasi quia dicta consequuntur et quia voluptatem iste id. Incidunt et rerum fuga esse sint.","created_at":"2016-03-22T15:20:35.905Z","updated_at":"2016-03-22T15:20:35.905Z","started_at":null,"runner_id":null,"coverage":null,"commit_id":39,"commands":"$ build command","job_id":null,"name":"test build 1","deploy":false,"options":null,"allow_failure":false,"stage":"test","trigger_request_id":null,"stage_idx":1,"tag":null,"ref":"master","user_id":null,"target_url":null,"description":null,"erased_by_id":null,"erased_at":null}]}]}
+{"id":41,"project_id":5,"ref":"master","sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","before_sha":null,"push_data":null,"created_at":"2016-03-22T15:20:35.763Z","updated_at":"2016-03-22T15:20:35.763Z","tag":null,"yaml_errors":null,"committed_at":null,"status":"failed","started_at":null,"finished_at":null,"duration":null,"stages":[]}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/container_expiration_policy.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/container_expiration_policy.ndjson
new file mode 100644
index 00000000000..033eee9751c
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/container_expiration_policy.ndjson
@@ -0,0 +1 @@
+{"created_at":"2019-12-13 13:45:04 UTC","updated_at":"2019-12-13 13:45:04 UTC","next_run_at":null,"project_id":5,"name_regex":null,"cadence":"3month","older_than":null,"keep_n":100,"enabled":false}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/custom_attributes.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/custom_attributes.ndjson
new file mode 100644
index 00000000000..cf232f80c9b
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/custom_attributes.ndjson
@@ -0,0 +1,2 @@
+{"id":1,"created_at":"2017-10-19T15:36:23.466Z","updated_at":"2017-10-19T15:36:23.466Z","project_id":5,"key":"foo","value":"foo"}
+{"id":2,"created_at":"2017-10-19T15:37:21.904Z","updated_at":"2017-10-19T15:37:21.904Z","project_id":5,"key":"bar","value":"bar"}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/error_tracking_setting.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/error_tracking_setting.ndjson
new file mode 100644
index 00000000000..c95db1c86a7
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/error_tracking_setting.ndjson
@@ -0,0 +1 @@
+{"api_url":"https://gitlab.example.com/api/0/projects/sentry-org/sentry-project","project_name":"Sentry Project","organization_name":"Sentry Org"}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/external_pull_requests.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/external_pull_requests.ndjson
new file mode 100644
index 00000000000..718678ef5cd
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/external_pull_requests.ndjson
@@ -0,0 +1 @@
+{"id":3,"pull_request_iid":4,"source_branch":"feature","target_branch":"master","source_repository":"the-repository","target_repository":"the-repository","source_sha":"ce84140e8b878ce6e7c4d298c7202ff38170e3ac","target_sha":"a09386439ca39abe575675ffd4b89ae824fec22f","status":"open","created_at":"2019-12-24T14:04:50.053Z","updated_at":"2019-12-24T14:05:18.138Z"}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/issues.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/issues.ndjson
new file mode 100644
index 00000000000..2ebd1a78783
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/issues.ndjson
@@ -0,0 +1,10 @@
+{"id":40,"title":"Voluptatem","author_id":22,"project_id":5,"created_at":"2016-06-14T15:02:08.340Z","updated_at":"2016-06-14T15:02:47.967Z","position":0,"branch_name":null,"description":"Aliquam enim illo et possimus.","state":"opened","iid":10,"updated_by_id":null,"confidential":false,"due_date":null,"moved_to_id":null,"test_ee_field":"test","issue_assignees":[{"user_id":1,"issue_id":40},{"user_id":15,"issue_id":40},{"user_id":16,"issue_id":40},{"user_id":16,"issue_id":40},{"user_id":6,"issue_id":40}],"award_emoji":[{"id":1,"name":"musical_keyboard","user_id":1,"awardable_type":"Issue","awardable_id":40,"created_at":"2020-01-07T11:55:22.234Z","updated_at":"2020-01-07T11:55:22.234Z"}],"zoom_meetings":[{"id":1,"project_id":5,"issue_id":40,"url":"https://zoom.us/j/123456789","issue_status":1,"created_at":"2016-06-14T15:02:04.418Z","updated_at":"2016-06-14T15:02:04.418Z"}],"milestone":{"id":1,"title":"test milestone","project_id":8,"description":"test milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"events":[{"id":487,"target_type":"Milestone","target_id":1,"project_id":46,"created_at":"2016-06-14T15:02:04.418Z","updated_at":"2016-06-14T15:02:04.418Z","action":1,"author_id":18}]},"label_links":[{"id":2,"label_id":2,"target_id":40,"target_type":"Issue","created_at":"2016-07-22T08:57:02.840Z","updated_at":"2016-07-22T08:57:02.840Z","label":{"id":2,"title":"test2","color":"#428bca","project_id":8,"created_at":"2016-07-22T08:55:44.161Z","updated_at":"2016-07-22T08:55:44.161Z","template":false,"description":"","type":"ProjectLabel"}},{"id":3,"label_id":3,"target_id":40,"target_type":"Issue","created_at":"2016-07-22T08:57:02.841Z","updated_at":"2016-07-22T08:57:02.841Z","label":{"id":3,"title":"test3","color":"#428bca","group_id":8,"created_at":"2016-07-22T08:55:44.161Z","updated_at":"2016-07-22T08:55:44.161Z","template":false,"description":"","project_id":null,"type":"GroupLabel","priorities":[{"id":1,"project_id":5,"label_id":1,"priority":1,"created_at":"2016-10-18T09:35:43.338Z","updated_at":"2016-10-18T09:35:43.338Z"}]}}],"notes":[{"id":351,"note":"Quo reprehenderit aliquam qui dicta impedit cupiditate eligendi.","note_html":"<p>something else entirely</p>","cached_markdown_version":917504,"noteable_type":"Issue","author_id":26,"created_at":"2016-06-14T15:02:47.770Z","updated_at":"2016-06-14T15:02:47.770Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":40,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[],"award_emoji":[{"id":1,"name":"clapper","user_id":1,"awardable_type":"Note","awardable_id":351,"created_at":"2020-01-07T11:55:22.234Z","updated_at":"2020-01-07T11:55:22.234Z"}]},{"id":352,"note":"Est reprehenderit quas aut aspernatur autem recusandae voluptatem.","noteable_type":"Issue","author_id":25,"created_at":"2016-06-14T15:02:47.795Z","updated_at":"2016-06-14T15:02:47.795Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":40,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":353,"note":"Perspiciatis suscipit voluptates in eius nihil.","noteable_type":"Issue","author_id":22,"created_at":"2016-06-14T15:02:47.823Z","updated_at":"2016-06-14T15:02:47.823Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":40,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":354,"note":"Aut vel voluptas corrupti nisi provident laboriosam magnam aut.","noteable_type":"Issue","author_id":20,"created_at":"2016-06-14T15:02:47.850Z","updated_at":"2016-06-14T15:02:47.850Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":40,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":355,"note":"Officia dolore consequatur in saepe cum magni.","noteable_type":"Issue","author_id":16,"created_at":"2016-06-14T15:02:47.876Z","updated_at":"2016-06-14T15:02:47.876Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":40,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":356,"note":"Cum ipsum rem voluptas eaque et ea.","noteable_type":"Issue","author_id":15,"created_at":"2016-06-14T15:02:47.908Z","updated_at":"2016-06-14T15:02:47.908Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":40,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":357,"note":"Recusandae excepturi asperiores suscipit autem nostrum.","noteable_type":"Issue","author_id":6,"created_at":"2016-06-14T15:02:47.937Z","updated_at":"2016-06-14T15:02:47.937Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":40,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":358,"note":"Et hic est id similique et non nesciunt voluptate.","noteable_type":"Issue","author_id":1,"created_at":"2016-06-14T15:02:47.965Z","updated_at":"2016-06-14T15:02:47.965Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":40,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"resource_label_events":[{"id":244,"action":"remove","issue_id":40,"merge_request_id":null,"label_id":2,"user_id":1,"created_at":"2018-08-28T08:24:00.494Z","label":{"id":2,"title":"test2","color":"#428bca","project_id":8,"created_at":"2016-07-22T08:55:44.161Z","updated_at":"2016-07-22T08:55:44.161Z","template":false,"description":"","type":"ProjectLabel"}}],"sentry_issue":{"id":1,"issue_id":40,"sentry_issue_identifier":1234567891}}
+{"id":39,"title":"Issue without assignees","author_id":22,"project_id":5,"created_at":"2016-06-14T15:02:08.233Z","updated_at":"2016-06-14T15:02:48.194Z","position":0,"branch_name":null,"description":"Voluptate vel reprehenderit facilis omnis voluptas magnam tenetur.","state":"opened","iid":9,"updated_by_id":null,"confidential":false,"due_date":null,"moved_to_id":null,"issue_assignees":[],"milestone":{"id":1,"title":"test milestone","project_id":8,"description":"test milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"events":[{"id":487,"target_type":"Milestone","target_id":1,"project_id":46,"created_at":"2016-06-14T15:02:04.418Z","updated_at":"2016-06-14T15:02:04.418Z","action":1,"author_id":18}]},"notes":[{"id":359,"note":"Quo eius velit quia et id quam.","noteable_type":"Issue","author_id":26,"created_at":"2016-06-14T15:02:48.009Z","updated_at":"2016-06-14T15:02:48.009Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":360,"note":"Nulla commodi ratione cumque id autem.","noteable_type":"Issue","author_id":25,"created_at":"2016-06-14T15:02:48.032Z","updated_at":"2016-06-14T15:02:48.032Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":361,"note":"Illum non ea sed dolores corrupti.","noteable_type":"Issue","author_id":22,"created_at":"2016-06-14T15:02:48.056Z","updated_at":"2016-06-14T15:02:48.056Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":362,"note":"Facere dolores ipsum dolorum maiores omnis occaecati ab.","noteable_type":"Issue","author_id":20,"created_at":"2016-06-14T15:02:48.082Z","updated_at":"2016-06-14T15:02:48.082Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":363,"note":"Quod laudantium similique sint aut est ducimus.","noteable_type":"Issue","author_id":16,"created_at":"2016-06-14T15:02:48.113Z","updated_at":"2016-06-14T15:02:48.113Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":364,"note":"Aut omnis eos esse incidunt vero reiciendis.","noteable_type":"Issue","author_id":15,"created_at":"2016-06-14T15:02:48.139Z","updated_at":"2016-06-14T15:02:48.139Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":365,"note":"Beatae dolore et doloremque asperiores sunt.","noteable_type":"Issue","author_id":6,"created_at":"2016-06-14T15:02:48.162Z","updated_at":"2016-06-14T15:02:48.162Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":366,"note":"Doloribus ipsam ex delectus rerum libero recusandae modi repellendus.","noteable_type":"Issue","author_id":1,"created_at":"2016-06-14T15:02:48.192Z","updated_at":"2016-06-14T15:02:48.192Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}]}
+{"id":38,"title":"Quasi adipisci non cupiditate dolorem quo qui earum sed.","author_id":6,"project_id":5,"created_at":"2016-06-14T15:02:08.154Z","updated_at":"2016-06-14T15:02:48.614Z","position":0,"branch_name":null,"description":"Ea recusandae neque autem tempora.","state":"closed","iid":8,"updated_by_id":null,"confidential":false,"due_date":null,"moved_to_id":null,"label_links":[{"id":99,"label_id":2,"target_id":38,"target_type":"Issue","created_at":"2016-07-22T08:57:02.840Z","updated_at":"2016-07-22T08:57:02.840Z","label":{"id":2,"title":"test2","color":"#428bca","project_id":8,"created_at":"2016-07-22T08:55:44.161Z","updated_at":"2016-07-22T08:55:44.161Z","template":false,"description":"","type":"ProjectLabel"}}],"notes":[{"id":367,"note":"Accusantium fugiat et eaque quisquam esse corporis.","noteable_type":"Issue","author_id":26,"created_at":"2016-06-14T15:02:48.235Z","updated_at":"2016-06-14T15:02:48.235Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":38,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":368,"note":"Ea labore eum nam qui laboriosam.","noteable_type":"Issue","author_id":25,"created_at":"2016-06-14T15:02:48.261Z","updated_at":"2016-06-14T15:02:48.261Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":38,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":369,"note":"Accusantium quis sed molestiae et.","noteable_type":"Issue","author_id":22,"created_at":"2016-06-14T15:02:48.294Z","updated_at":"2016-06-14T15:02:48.294Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":38,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":370,"note":"Corporis numquam a voluptatem pariatur asperiores dolorem delectus autem.","noteable_type":"Issue","author_id":20,"created_at":"2016-06-14T15:02:48.523Z","updated_at":"2016-06-14T15:02:48.523Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":38,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":371,"note":"Ea accusantium maxime voluptas rerum.","noteable_type":"Issue","author_id":16,"created_at":"2016-06-14T15:02:48.546Z","updated_at":"2016-06-14T15:02:48.546Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":38,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":372,"note":"Pariatur iusto et et excepturi similique ipsam eum.","noteable_type":"Issue","author_id":15,"created_at":"2016-06-14T15:02:48.569Z","updated_at":"2016-06-14T15:02:48.569Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":38,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":373,"note":"Aliquam et culpa officia iste eius.","noteable_type":"Issue","author_id":6,"created_at":"2016-06-14T15:02:48.591Z","updated_at":"2016-06-14T15:02:48.591Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":38,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":374,"note":"Ab id velit id unde laborum.","noteable_type":"Issue","author_id":1,"created_at":"2016-06-14T15:02:48.613Z","updated_at":"2016-06-14T15:02:48.613Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":38,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}]}
+{"id":37,"title":"Cupiditate quo aut ducimus minima molestiae vero numquam possimus.","author_id":20,"project_id":5,"created_at":"2016-06-14T15:02:08.051Z","updated_at":"2016-06-14T15:02:48.854Z","position":0,"branch_name":null,"description":"Maiores architecto quos in dolorem.","state":"opened","iid":7,"updated_by_id":null,"confidential":false,"due_date":null,"moved_to_id":null,"notes":[{"id":375,"note":"Quasi fugit qui sed eligendi aut quia.","noteable_type":"Issue","author_id":26,"created_at":"2016-06-14T15:02:48.647Z","updated_at":"2016-06-14T15:02:48.647Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":37,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":376,"note":"Esse nesciunt voluptatem ex vero est consequatur.","noteable_type":"Issue","author_id":25,"created_at":"2016-06-14T15:02:48.674Z","updated_at":"2016-06-14T15:02:48.674Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":37,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":377,"note":"Similique qui quas non aut et velit sequi in.","noteable_type":"Issue","author_id":22,"created_at":"2016-06-14T15:02:48.696Z","updated_at":"2016-06-14T15:02:48.696Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":37,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":378,"note":"Eveniet ut cupiditate repellendus numquam in esse eius.","noteable_type":"Issue","author_id":20,"created_at":"2016-06-14T15:02:48.720Z","updated_at":"2016-06-14T15:02:48.720Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":37,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":379,"note":"Velit est dolorem adipisci rerum sed iure.","noteable_type":"Issue","author_id":16,"created_at":"2016-06-14T15:02:48.755Z","updated_at":"2016-06-14T15:02:48.755Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":37,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":380,"note":"Voluptatem ullam ab ut illo ut quo.","noteable_type":"Issue","author_id":15,"created_at":"2016-06-14T15:02:48.793Z","updated_at":"2016-06-14T15:02:48.793Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":37,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":381,"note":"Voluptatem impedit beatae quasi ipsa earum consectetur.","noteable_type":"Issue","author_id":6,"created_at":"2016-06-14T15:02:48.823Z","updated_at":"2016-06-14T15:02:48.823Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":37,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":382,"note":"Nihil officiis eaque incidunt sunt voluptatum excepturi.","noteable_type":"Issue","author_id":1,"created_at":"2016-06-14T15:02:48.852Z","updated_at":"2016-06-14T15:02:48.852Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":37,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}]}
+{"id":36,"title":"Necessitatibus dolor est enim quia rem suscipit quidem voluptas ullam.","author_id":16,"project_id":5,"created_at":"2016-06-14T15:02:07.958Z","updated_at":"2016-06-14T15:02:49.044Z","position":0,"branch_name":null,"description":"Ut aut ut et tenetur velit aut id modi.","state":"opened","iid":6,"updated_by_id":null,"confidential":false,"due_date":null,"moved_to_id":null,"notes":[{"id":383,"note":"Excepturi deleniti sunt rerum nesciunt vero fugiat possimus.","noteable_type":"Issue","author_id":26,"created_at":"2016-06-14T15:02:48.885Z","updated_at":"2016-06-14T15:02:48.885Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":36,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":384,"note":"Et est nemo sed nam sed.","noteable_type":"Issue","author_id":25,"created_at":"2016-06-14T15:02:48.910Z","updated_at":"2016-06-14T15:02:48.910Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":36,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":385,"note":"Animi mollitia nulla facere amet aut quaerat.","noteable_type":"Issue","author_id":22,"created_at":"2016-06-14T15:02:48.934Z","updated_at":"2016-06-14T15:02:48.934Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":36,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":386,"note":"Excepturi id voluptas ut odio officiis omnis.","noteable_type":"Issue","author_id":20,"created_at":"2016-06-14T15:02:48.955Z","updated_at":"2016-06-14T15:02:48.955Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":36,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":387,"note":"Molestiae labore officiis magni et eligendi quasi maxime.","noteable_type":"Issue","author_id":16,"created_at":"2016-06-14T15:02:48.978Z","updated_at":"2016-06-14T15:02:48.978Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":36,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":388,"note":"Officia tenetur praesentium rem nam non.","noteable_type":"Issue","author_id":15,"created_at":"2016-06-14T15:02:49.001Z","updated_at":"2016-06-14T15:02:49.001Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":36,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":389,"note":"Et et et molestiae reprehenderit.","noteable_type":"Issue","author_id":6,"created_at":"2016-06-14T15:02:49.022Z","updated_at":"2016-06-14T15:02:49.022Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":36,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":390,"note":"Aperiam in consequatur est sunt cum quia.","noteable_type":"Issue","author_id":1,"created_at":"2016-06-14T15:02:49.043Z","updated_at":"2016-06-14T15:02:49.043Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":36,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}]}
+{"id":35,"title":"Repellat praesentium deserunt maxime incidunt harum porro qui.","author_id":20,"project_id":5,"created_at":"2016-06-14T15:02:07.832Z","updated_at":"2016-06-14T15:02:49.226Z","position":0,"branch_name":null,"description":"Dicta nisi nihil non ipsa velit.","state":"closed","iid":5,"updated_by_id":null,"confidential":false,"due_date":null,"moved_to_id":null,"notes":[{"id":391,"note":"Qui magnam et assumenda quod id dicta necessitatibus.","noteable_type":"Issue","author_id":26,"created_at":"2016-06-14T15:02:49.075Z","updated_at":"2016-06-14T15:02:49.075Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":35,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":392,"note":"Consectetur deserunt possimus dolor est odio.","noteable_type":"Issue","author_id":25,"created_at":"2016-06-14T15:02:49.095Z","updated_at":"2016-06-14T15:02:49.095Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":35,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":393,"note":"Labore nisi quo cumque voluptas consequatur aut qui.","noteable_type":"Issue","author_id":22,"created_at":"2016-06-14T15:02:49.117Z","updated_at":"2016-06-14T15:02:49.117Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":35,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":394,"note":"Et totam facilis voluptas et enim.","noteable_type":"Issue","author_id":20,"created_at":"2016-06-14T15:02:49.138Z","updated_at":"2016-06-14T15:02:49.138Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":35,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":395,"note":"Ratione sint pariatur sed omnis eligendi quo libero exercitationem.","noteable_type":"Issue","author_id":16,"created_at":"2016-06-14T15:02:49.160Z","updated_at":"2016-06-14T15:02:49.160Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":35,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":396,"note":"Iure hic autem id voluptatem.","noteable_type":"Issue","author_id":15,"created_at":"2016-06-14T15:02:49.182Z","updated_at":"2016-06-14T15:02:49.182Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":35,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":397,"note":"Excepturi eum laboriosam delectus repellendus odio nisi et voluptatem.","noteable_type":"Issue","author_id":6,"created_at":"2016-06-14T15:02:49.205Z","updated_at":"2016-06-14T15:02:49.205Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":35,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":398,"note":"Ut quis ex soluta consequatur et blanditiis.","noteable_type":"Issue","author_id":1,"created_at":"2016-06-14T15:02:49.225Z","updated_at":"2016-06-14T15:02:49.225Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":35,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}]}
+{"id":34,"title":"Ullam expedita deserunt libero consequatur quia dolor harum perferendis facere quidem.","author_id":1,"project_id":5,"created_at":"2016-06-14T15:02:07.717Z","updated_at":"2016-06-14T15:02:49.416Z","position":0,"branch_name":null,"description":"Ut et explicabo vel voluptatem consequuntur ut sed.","state":"closed","iid":4,"updated_by_id":null,"confidential":false,"due_date":null,"moved_to_id":null,"notes":[{"id":399,"note":"Dolor iste tempora tenetur non vitae maiores voluptatibus.","noteable_type":"Issue","author_id":26,"created_at":"2016-06-14T15:02:49.256Z","updated_at":"2016-06-14T15:02:49.256Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":34,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":400,"note":"Aut sit quidem qui adipisci maxime excepturi iusto.","noteable_type":"Issue","author_id":25,"created_at":"2016-06-14T15:02:49.284Z","updated_at":"2016-06-14T15:02:49.284Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":34,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":401,"note":"Et a necessitatibus autem quidem animi sunt voluptatum rerum.","noteable_type":"Issue","author_id":22,"created_at":"2016-06-14T15:02:49.305Z","updated_at":"2016-06-14T15:02:49.305Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":34,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":402,"note":"Esse laboriosam quo voluptatem quis molestiae.","noteable_type":"Issue","author_id":20,"created_at":"2016-06-14T15:02:49.328Z","updated_at":"2016-06-14T15:02:49.328Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":34,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":403,"note":"Nemo magnam distinctio est ut voluptate ea.","noteable_type":"Issue","author_id":16,"created_at":"2016-06-14T15:02:49.350Z","updated_at":"2016-06-14T15:02:49.350Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":34,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":404,"note":"Omnis sed rerum neque rerum quae quam nulla officiis.","noteable_type":"Issue","author_id":15,"created_at":"2016-06-14T15:02:49.372Z","updated_at":"2016-06-14T15:02:49.372Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":34,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":405,"note":"Quo soluta dolorem vitae ad consequatur qui aut dicta.","noteable_type":"Issue","author_id":6,"created_at":"2016-06-14T15:02:49.394Z","updated_at":"2016-06-14T15:02:49.394Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":34,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":406,"note":"Magni minus est aut aut totam ut.","noteable_type":"Issue","author_id":1,"created_at":"2016-06-14T15:02:49.414Z","updated_at":"2016-06-14T15:02:49.414Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":34,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}]}
+{"id":33,"title":"Numquam accusamus eos iste exercitationem magni non inventore.","author_id":26,"project_id":5,"created_at":"2016-06-14T15:02:07.611Z","updated_at":"2016-06-14T15:02:49.661Z","position":0,"branch_name":null,"description":"Non asperiores velit accusantium voluptate.","state":"closed","iid":3,"updated_by_id":null,"confidential":false,"due_date":null,"moved_to_id":null,"notes":[{"id":407,"note":"Quod ea et possimus architecto.","noteable_type":"Issue","author_id":26,"created_at":"2016-06-14T15:02:49.450Z","updated_at":"2016-06-14T15:02:49.450Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":33,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":408,"note":"Reiciendis est et unde perferendis dicta ut praesentium quasi.","noteable_type":"Issue","author_id":25,"created_at":"2016-06-14T15:02:49.503Z","updated_at":"2016-06-14T15:02:49.503Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":33,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":409,"note":"Magni quia odio blanditiis pariatur voluptas.","noteable_type":"Issue","author_id":22,"created_at":"2016-06-14T15:02:49.527Z","updated_at":"2016-06-14T15:02:49.527Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":33,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":410,"note":"Enim quam ut et et et.","noteable_type":"Issue","author_id":20,"created_at":"2016-06-14T15:02:49.551Z","updated_at":"2016-06-14T15:02:49.551Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":33,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":411,"note":"Fugit voluptatem ratione maxime expedita.","noteable_type":"Issue","author_id":16,"created_at":"2016-06-14T15:02:49.578Z","updated_at":"2016-06-14T15:02:49.578Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":33,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":412,"note":"Voluptatem enim aut ipsa et et ducimus.","noteable_type":"Issue","author_id":15,"created_at":"2016-06-14T15:02:49.604Z","updated_at":"2016-06-14T15:02:49.604Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":33,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":413,"note":"Quia repellat fugiat consectetur quidem.","noteable_type":"Issue","author_id":6,"created_at":"2016-06-14T15:02:49.631Z","updated_at":"2016-06-14T15:02:49.631Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":33,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":414,"note":"Corporis ipsum et ea necessitatibus quod assumenda repudiandae quam.","noteable_type":"Issue","author_id":1,"created_at":"2016-06-14T15:02:49.659Z","updated_at":"2016-06-14T15:02:49.659Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":33,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}]}
+{"id":32,"title":"Necessitatibus magnam qui at velit consequatur perspiciatis.","author_id":15,"project_id":5,"created_at":"2016-06-14T15:02:07.431Z","updated_at":"2016-06-14T15:02:49.884Z","position":0,"branch_name":null,"description":"Molestiae corporis magnam et fugit aliquid nulla quia.","state":"closed","iid":2,"updated_by_id":null,"confidential":false,"due_date":null,"moved_to_id":null,"notes":[{"id":415,"note":"Nemo consequatur sed blanditiis qui id iure dolores.","noteable_type":"Issue","author_id":26,"created_at":"2016-06-14T15:02:49.694Z","updated_at":"2016-06-14T15:02:49.694Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":32,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":416,"note":"Voluptas ab accusantium dicta in.","noteable_type":"Issue","author_id":25,"created_at":"2016-06-14T15:02:49.718Z","updated_at":"2016-06-14T15:02:49.718Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":32,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":417,"note":"Esse odit qui a et eum ducimus.","noteable_type":"Issue","author_id":22,"created_at":"2016-06-14T15:02:49.741Z","updated_at":"2016-06-14T15:02:49.741Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":32,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":418,"note":"Sequi dolor doloribus ratione placeat repellendus.","noteable_type":"Issue","author_id":20,"created_at":"2016-06-14T15:02:49.767Z","updated_at":"2016-06-14T15:02:49.767Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":32,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":419,"note":"Quae aspernatur rem est similique.","noteable_type":"Issue","author_id":16,"created_at":"2016-06-14T15:02:49.796Z","updated_at":"2016-06-14T15:02:49.796Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":32,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":420,"note":"Voluptate omnis et id rerum non nesciunt laudantium assumenda.","noteable_type":"Issue","author_id":15,"created_at":"2016-06-14T15:02:49.825Z","updated_at":"2016-06-14T15:02:49.825Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":32,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":421,"note":"Quia enim ab et eligendi.","noteable_type":"Issue","author_id":6,"created_at":"2016-06-14T15:02:49.853Z","updated_at":"2016-06-14T15:02:49.853Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":32,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":422,"note":"In fugiat rerum voluptas quas officia.","noteable_type":"Issue","author_id":1,"created_at":"2016-06-14T15:02:49.881Z","updated_at":"2016-06-14T15:02:49.881Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":32,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}]}
+{"id":31,"title":"issue_with_timelogs","author_id":16,"project_id":5,"created_at":"2016-06-14T15:02:07.280Z","updated_at":"2016-06-14T15:02:50.134Z","position":0,"branch_name":null,"description":"Quod ad architecto qui est sed quia.","state":"closed","iid":1,"updated_by_id":null,"confidential":false,"due_date":null,"moved_to_id":null,"timelogs":[{"id":1,"time_spent":72000,"user_id":1,"created_at":"2019-12-27T09:15:22.302Z","updated_at":"2019-12-27T09:15:22.302Z","spent_at":"2019-12-27T00:00:00.000Z"}],"notes":[{"id":423,"note":"A mollitia qui iste consequatur eaque iure omnis sunt.","noteable_type":"Issue","author_id":26,"created_at":"2016-06-14T15:02:49.933Z","updated_at":"2016-06-14T15:02:49.933Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":31,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":424,"note":"Eveniet est et blanditiis sequi alias.","noteable_type":"Issue","author_id":25,"created_at":"2016-06-14T15:02:49.965Z","updated_at":"2016-06-14T15:02:49.965Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":31,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":425,"note":"Commodi tempore voluptas doloremque est.","noteable_type":"Issue","author_id":22,"created_at":"2016-06-14T15:02:49.996Z","updated_at":"2016-06-14T15:02:49.996Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":31,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":426,"note":"Quo libero impedit odio debitis rerum aspernatur.","noteable_type":"Issue","author_id":20,"created_at":"2016-06-14T15:02:50.024Z","updated_at":"2016-06-14T15:02:50.024Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":31,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":427,"note":"Dolorem voluptatem qui labore deserunt.","noteable_type":"Issue","author_id":16,"created_at":"2016-06-14T15:02:50.049Z","updated_at":"2016-06-14T15:02:50.049Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":31,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":428,"note":"Est blanditiis laboriosam enim ipsam.","noteable_type":"Issue","author_id":15,"created_at":"2016-06-14T15:02:50.077Z","updated_at":"2016-06-14T15:02:50.077Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":31,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":429,"note":"Et in voluptatem animi dolorem eos.","noteable_type":"Issue","author_id":6,"created_at":"2016-06-14T15:02:50.107Z","updated_at":"2016-06-14T15:02:50.107Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":31,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":430,"note":"Unde culpa voluptate qui sint quos.","noteable_type":"Issue","author_id":1,"created_at":"2016-06-14T15:02:50.132Z","updated_at":"2016-06-14T15:02:50.132Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":31,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/labels.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/labels.ndjson
new file mode 100644
index 00000000000..c36b6970e83
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/labels.ndjson
@@ -0,0 +1,2 @@
+{"id":2,"title":"test2","color":"#428bca","project_id":8,"created_at":"2016-07-22T08:55:44.161Z","updated_at":"2016-07-22T08:55:44.161Z","template":false,"description":"","type":"ProjectLabel","priorities":[]}
+{"id":3,"title":"test3","color":"#428bca","group_id":8,"created_at":"2016-07-22T08:55:44.161Z","updated_at":"2016-07-22T08:55:44.161Z","template":false,"description":"","project_id":null,"type":"GroupLabel","priorities":[{"id":1,"project_id":5,"label_id":1,"priority":1,"created_at":"2016-10-18T09:35:43.338Z","updated_at":"2016-10-18T09:35:43.338Z"}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/merge_requests.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/merge_requests.ndjson
new file mode 100644
index 00000000000..3687c005b96
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/merge_requests.ndjson
@@ -0,0 +1,9 @@
+{"id":27,"target_branch":"feature","source_branch":"feature_conflict","source_project_id":2147483547,"author_id":1,"assignee_id":null,"title":"MR1","created_at":"2016-06-14T15:02:36.568Z","updated_at":"2016-06-14T15:02:56.815Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":9,"description":null,"position":0,"updated_by_id":null,"merge_error":null,"diff_head_sha":"HEAD","source_branch_sha":"ABCD","target_branch_sha":"DCBA","merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":true,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":669,"note":"added 3 commits\n\n<ul><li>16ea4e20...074a2a32 - 2 commits from branch <code>master</code></li><li>ca223a02 - readme: fix typos</li></ul>\n\n[Compare with previous version](/group/project/merge_requests/1/diffs?diff_id=1189&start_sha=16ea4e207fb258fe4e9c73185a725207c9a4f3e1)","noteable_type":"MergeRequest","author_id":26,"created_at":"2020-03-28T12:47:33.461Z","updated_at":"2020-03-28T12:47:33.461Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":null,"change_position":null,"resolved_by_push":null,"confidential":null,"type":null,"author":{"name":"User 4"},"award_emoji":[],"system_note_metadata":{"id":4789,"commit_count":3,"action":"commit","created_at":"2020-03-28T12:47:33.461Z","updated_at":"2020-03-28T12:47:33.461Z"},"events":[],"suggestions":[]},{"id":670,"note":"unmarked as a **Work In Progress**","noteable_type":"MergeRequest","author_id":26,"created_at":"2020-03-28T12:48:36.951Z","updated_at":"2020-03-28T12:48:36.951Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":null,"change_position":null,"resolved_by_push":null,"confidential":null,"type":null,"author":{"name":"User 4"},"award_emoji":[],"system_note_metadata":{"id":4790,"commit_count":null,"action":"title","created_at":"2020-03-28T12:48:36.951Z","updated_at":"2020-03-28T12:48:36.951Z"},"events":[],"suggestions":[]},{"id":671,"note":"Sit voluptatibus eveniet architecto quidem.","note_html":"<p>something else entirely</p>","cached_markdown_version":917504,"noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:56.632Z","updated_at":"2016-06-14T15:02:56.632Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[],"award_emoji":[{"id":1,"name":"tada","user_id":1,"awardable_type":"Note","awardable_id":1,"created_at":"2019-11-05T15:37:21.287Z","updated_at":"2019-11-05T15:37:21.287Z"}]},{"id":672,"note":"Odio maxime ratione voluptatibus sed.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:56.656Z","updated_at":"2016-06-14T15:02:56.656Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":673,"note":"Et deserunt et omnis nihil excepturi accusantium.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:56.679Z","updated_at":"2016-06-14T15:02:56.679Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":674,"note":"Saepe asperiores exercitationem non dignissimos laborum reiciendis et ipsum.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:56.700Z","updated_at":"2016-06-14T15:02:56.700Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[],"suggestions":[{"id":1,"note_id":674,"relative_order":0,"applied":false,"commit_id":null,"from_content":"Original line\n","to_content":"New line\n","lines_above":0,"lines_below":0,"outdated":false}]},{"id":675,"note":"Numquam est at dolor quo et sed eligendi similique.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:56.720Z","updated_at":"2016-06-14T15:02:56.720Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":676,"note":"Et perferendis aliquam sunt nisi labore delectus.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:56.742Z","updated_at":"2016-06-14T15:02:56.742Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":677,"note":"Aut ex rerum et in.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:56.791Z","updated_at":"2016-06-14T15:02:56.791Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":678,"note":"Dolor laborum earum ut exercitationem.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:56.814Z","updated_at":"2016-06-14T15:02:56.814Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"resource_label_events":[{"id":243,"action":"add","issue_id":null,"merge_request_id":27,"label_id":null,"user_id":1,"created_at":"2018-08-28T08:24:00.494Z"}],"merge_request_diff":{"id":27,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":27,"relative_order":0,"sha":"bb5206fee213d983da88c47f9cf4cc6caf9c66dc","message":"Feature conflict added\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-08-06T08:35:52.000+02:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-08-06T08:35:52.000+02:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"},{"merge_request_diff_id":27,"relative_order":1,"sha":"5937ac0a7beb003549fc5fd26fc247adbce4a52e","message":"Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T10:01:38.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T10:01:38.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"},{"merge_request_diff_id":27,"relative_order":2,"sha":"570e7b2abdd848b95f2f578043fc23bd6f6fd24d","message":"Change some files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T09:57:31.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:57:31.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"},{"merge_request_diff_id":27,"relative_order":3,"sha":"6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9","message":"More submodules\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T09:54:21.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:54:21.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"},{"merge_request_diff_id":27,"relative_order":4,"sha":"d14d6c0abdd253381df51a723d58691b2ee1ab08","message":"Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T09:49:50.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:49:50.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"},{"merge_request_diff_id":27,"relative_order":5,"sha":"c1acaa58bbcbc3eafe538cb8274ba387047b69f8","message":"Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T09:48:32.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:48:32.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"}],"merge_request_diff_files":[{"merge_request_diff_id":27,"relative_order":0,"utf8_diff":"Binary files a/.DS_Store and /dev/null differ\n","new_path":".DS_Store","old_path":".DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":27,"relative_order":1,"utf8_diff":"--- a/.gitignore\n+++ b/.gitignore\n@@ -17,3 +17,4 @@ rerun.txt\n pickle-email-*.html\n .project\n config/initializers/secret_token.rb\n+.DS_Store\n","new_path":".gitignore","old_path":".gitignore","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":2,"utf8_diff":"--- a/.gitmodules\n+++ b/.gitmodules\n@@ -1,3 +1,9 @@\n [submodule \"six\"]\n \tpath = six\n \turl = git://github.com/randx/six.git\n+[submodule \"gitlab-shell\"]\n+\tpath = gitlab-shell\n+\turl = https://github.com/gitlabhq/gitlab-shell.git\n+[submodule \"gitlab-grack\"]\n+\tpath = gitlab-grack\n+\turl = https://gitlab.com/gitlab-org/gitlab-grack.git\n","new_path":".gitmodules","old_path":".gitmodules","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":3,"utf8_diff":"Binary files a/files/.DS_Store and /dev/null differ\n","new_path":"files/.DS_Store","old_path":"files/.DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":27,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/files/ruby/feature.rb\n@@ -0,0 +1,4 @@\n+# This file was changed in feature branch\n+# We put different code here to make merge conflict\n+class Conflict\n+end\n","new_path":"files/ruby/feature.rb","old_path":"files/ruby/feature.rb","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":5,"utf8_diff":"--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output << stdout.read\n @cmd_output << stderr.read\n","new_path":"files/ruby/popen.rb","old_path":"files/ruby/popen.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":6,"utf8_diff":"--- a/files/ruby/regex.rb\n+++ b/files/ruby/regex.rb\n@@ -19,14 +19,12 @@ module Gitlab\n end\n \n def archive_formats_regex\n- #|zip|tar| tar.gz | tar.bz2 |\n- /(zip|tar|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n+ /(zip|tar|7z|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n end\n \n def git_reference_regex\n # Valid git ref regex, see:\n # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html\n-\n %r{\n (?!\n (?# doesn't begins with)\n","new_path":"files/ruby/regex.rb","old_path":"files/ruby/regex.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":7,"utf8_diff":"--- /dev/null\n+++ b/gitlab-grack\n@@ -0,0 +1 @@\n+Subproject commit 645f6c4c82fd3f5e06f67134450a570b795e55a6\n","new_path":"gitlab-grack","old_path":"gitlab-grack","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":8,"utf8_diff":"--- /dev/null\n+++ b/gitlab-shell\n@@ -0,0 +1 @@\n+Subproject commit 79bceae69cb5750d6567b223597999bfa91cb3b9\n","new_path":"gitlab-shell","old_path":"gitlab-shell","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":27,"created_at":"2016-06-14T15:02:36.572Z","updated_at":"2016-06-14T15:02:36.658Z","base_commit_sha":"ae73cb07c9eeaf35924a10f713b364d32b2dd34f","real_size":"9"},"events":[{"id":221,"target_type":"MergeRequest","target_id":27,"project_id":36,"created_at":"2016-06-14T15:02:36.703Z","updated_at":"2016-06-14T15:02:36.703Z","action":1,"author_id":1},{"id":187,"target_type":"MergeRequest","target_id":27,"project_id":5,"created_at":"2016-06-14T15:02:36.703Z","updated_at":"2016-06-14T15:02:36.703Z","action":1,"author_id":1}],"approvals_before_merge":1,"award_emoji":[{"id":1,"name":"thumbsup","user_id":1,"awardable_type":"MergeRequest","awardable_id":27,"created_at":"2020-01-07T11:21:21.235Z","updated_at":"2020-01-07T11:21:21.235Z"},{"id":2,"name":"drum","user_id":1,"awardable_type":"MergeRequest","awardable_id":27,"created_at":"2020-01-07T11:21:21.235Z","updated_at":"2020-01-07T11:21:21.235Z"}]}
+{"id":26,"target_branch":"master","source_branch":"feature","source_project_id":4,"author_id":1,"assignee_id":null,"title":"MR2","created_at":"2016-06-14T15:02:36.418Z","updated_at":"2016-06-14T15:02:57.013Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":8,"description":null,"position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":679,"note":"Qui rerum totam nisi est.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:56.848Z","updated_at":"2016-06-14T15:02:56.848Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":680,"note":"Pariatur magni corrupti consequatur debitis minima error beatae voluptatem.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:56.871Z","updated_at":"2016-06-14T15:02:56.871Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":681,"note":"Qui quis ut modi eos rerum ratione.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:56.895Z","updated_at":"2016-06-14T15:02:56.895Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":682,"note":"Illum quidem expedita mollitia fugit.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:56.918Z","updated_at":"2016-06-14T15:02:56.918Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":683,"note":"Consectetur voluptate sit sint possimus veritatis quod.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:56.942Z","updated_at":"2016-06-14T15:02:56.942Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":684,"note":"Natus libero quibusdam rem assumenda deleniti accusamus sed earum.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:56.966Z","updated_at":"2016-06-14T15:02:56.966Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":685,"note":"Tenetur autem nihil rerum odit.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:56.989Z","updated_at":"2016-06-14T15:02:56.989Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":686,"note":"Quia maiores et odio sed.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:57.012Z","updated_at":"2016-06-14T15:02:57.012Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":26,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":26,"sha":"0b4bc9a49b562e85de7cc9e834518ea6828729b9","relative_order":0,"message":"Feature added\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T09:26:01.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:26:01.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"}],"merge_request_diff_files":[{"merge_request_diff_id":26,"relative_order":0,"utf8_diff":"--- /dev/null\n+++ b/files/ruby/feature.rb\n@@ -0,0 +1,5 @@\n+class Feature\n+ def foo\n+ puts 'bar'\n+ end\n+end\n","new_path":"files/ruby/feature.rb","old_path":"files/ruby/feature.rb","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":26,"created_at":"2016-06-14T15:02:36.421Z","updated_at":"2016-06-14T15:02:36.474Z","base_commit_sha":"ae73cb07c9eeaf35924a10f713b364d32b2dd34f","real_size":"1"},"events":[{"id":222,"target_type":"MergeRequest","target_id":26,"project_id":36,"created_at":"2016-06-14T15:02:36.496Z","updated_at":"2016-06-14T15:02:36.496Z","action":1,"author_id":1},{"id":186,"target_type":"MergeRequest","target_id":26,"project_id":5,"created_at":"2016-06-14T15:02:36.496Z","updated_at":"2016-06-14T15:02:36.496Z","action":1,"author_id":1}]}
+{"id":15,"target_branch":"test-7","source_branch":"test-1","source_project_id":5,"author_id":22,"assignee_id":16,"title":"Qui accusantium et inventore facilis doloribus occaecati officiis.","created_at":"2016-06-14T15:02:25.168Z","updated_at":"2016-06-14T15:02:59.521Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":7,"description":"Et commodi deserunt aspernatur vero rerum. Ut non dolorum alias in odit est libero. Voluptatibus eos in et vitae repudiandae facilis ex mollitia.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":777,"note":"Pariatur voluptas placeat aspernatur culpa suscipit soluta.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:59.348Z","updated_at":"2016-06-14T15:02:59.348Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":778,"note":"Alias et iure mollitia suscipit molestiae voluptatum nostrum asperiores.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:59.372Z","updated_at":"2016-06-14T15:02:59.372Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":779,"note":"Laudantium qui eum qui sunt.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:59.395Z","updated_at":"2016-06-14T15:02:59.395Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":780,"note":"Quas rem est iusto ut delectus fugiat recusandae mollitia.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:59.418Z","updated_at":"2016-06-14T15:02:59.418Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":781,"note":"Repellendus ab et qui nesciunt.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:59.444Z","updated_at":"2016-06-14T15:02:59.444Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":782,"note":"Non possimus voluptatum odio qui ut.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:59.469Z","updated_at":"2016-06-14T15:02:59.469Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":783,"note":"Dolores repellendus eum ducimus quam ab dolorem quia.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:59.494Z","updated_at":"2016-06-14T15:02:59.494Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":784,"note":"Facilis dolorem aut corrupti id ratione occaecati.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:59.520Z","updated_at":"2016-06-14T15:02:59.520Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":15,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":15,"relative_order":0,"sha":"94b8d581c48d894b86661718582fecbc5e3ed2eb","message":"fixes #10\n","authored_date":"2016-01-19T13:22:56.000+01:00","author_name":"James Lopez","author_email":"james@jameslopez.es","committed_date":"2016-01-19T13:22:56.000+01:00","committer_name":"James Lopez","committer_email":"james@jameslopez.es"}],"merge_request_diff_files":[{"merge_request_diff_id":15,"relative_order":0,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":15,"created_at":"2016-06-14T15:02:25.171Z","updated_at":"2016-06-14T15:02:25.230Z","base_commit_sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","real_size":"1"},"events":[{"id":223,"target_type":"MergeRequest","target_id":15,"project_id":36,"created_at":"2016-06-14T15:02:25.262Z","updated_at":"2016-06-14T15:02:25.262Z","action":1,"author_id":1},{"id":175,"target_type":"MergeRequest","target_id":15,"project_id":5,"created_at":"2016-06-14T15:02:25.262Z","updated_at":"2016-06-14T15:02:25.262Z","action":1,"author_id":22}]}
+{"id":14,"target_branch":"fix","source_branch":"test-3","source_project_id":5,"author_id":20,"assignee_id":20,"title":"In voluptas aut sequi voluptatem ullam vel corporis illum consequatur.","created_at":"2016-06-14T15:02:24.760Z","updated_at":"2016-06-14T15:02:59.749Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":6,"description":"Dicta magnam non voluptates nam dignissimos nostrum deserunt. Dolorum et suscipit iure quae doloremque. Necessitatibus saepe aut labore sed.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":785,"note":"Atque cupiditate necessitatibus deserunt minus natus odit.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:59.559Z","updated_at":"2016-06-14T15:02:59.559Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":786,"note":"Non dolorem provident mollitia nesciunt optio ex eveniet.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:59.587Z","updated_at":"2016-06-14T15:02:59.587Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":787,"note":"Similique officia nemo quasi commodi accusantium quae qui.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:59.621Z","updated_at":"2016-06-14T15:02:59.621Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":788,"note":"Et est et alias ad dolor qui.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:59.650Z","updated_at":"2016-06-14T15:02:59.650Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":789,"note":"Numquam temporibus ratione voluptatibus aliquid.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:59.675Z","updated_at":"2016-06-14T15:02:59.675Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":790,"note":"Ut ex aliquam consectetur perferendis est hic aut quia.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:59.703Z","updated_at":"2016-06-14T15:02:59.703Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":791,"note":"Esse eos quam quaerat aut ut asperiores officiis.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:59.726Z","updated_at":"2016-06-14T15:02:59.726Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":792,"note":"Sint facilis accusantium iure blanditiis.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:59.748Z","updated_at":"2016-06-14T15:02:59.748Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":14,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":14,"relative_order":0,"sha":"ddd4ff416a931589c695eb4f5b23f844426f6928","message":"fixes #10\n","authored_date":"2016-01-19T14:14:43.000+01:00","author_name":"James Lopez","author_email":"james@jameslopez.es","committed_date":"2016-01-19T14:14:43.000+01:00","committer_name":"James Lopez","committer_email":"james@jameslopez.es"},{"merge_request_diff_id":14,"relative_order":1,"sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","message":"Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6","authored_date":"2015-12-07T12:52:12.000+01:00","author_name":"Marin Jankovski","author_email":"marin@gitlab.com","committed_date":"2015-12-07T12:52:12.000+01:00","committer_name":"Marin Jankovski","committer_email":"marin@gitlab.com"},{"merge_request_diff_id":14,"relative_order":2,"sha":"048721d90c449b244b7b4c53a9186b04330174ec","message":"LFS object pointer.\n","authored_date":"2015-12-07T11:54:28.000+01:00","author_name":"Marin Jankovski","author_email":"maxlazio@gmail.com","committed_date":"2015-12-07T11:54:28.000+01:00","committer_name":"Marin Jankovski","committer_email":"maxlazio@gmail.com"},{"merge_request_diff_id":14,"relative_order":3,"sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","message":"GitLab currently doesn't support patches that involve a merge commit: add a commit here\n","authored_date":"2015-11-13T16:27:12.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T16:27:12.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":14,"relative_order":4,"sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","message":"Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5","authored_date":"2015-11-13T08:50:17.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:50:17.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":14,"relative_order":5,"sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","message":"Add GitLab SVG\n","authored_date":"2015-11-13T08:39:43.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:39:43.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":14,"relative_order":6,"sha":"59e29889be61e6e0e5e223bfa9ac2721d31605b8","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4","authored_date":"2015-11-13T07:21:40.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T07:21:40.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":14,"relative_order":7,"sha":"66eceea0db202bb39c4e445e8ca28689645366c5","message":"add spaces in whitespace file\n","authored_date":"2015-11-13T06:01:27.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:01:27.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":14,"relative_order":8,"sha":"08f22f255f082689c0d7d39d19205085311542bc","message":"remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n","authored_date":"2015-11-13T06:00:16.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:00:16.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":14,"relative_order":9,"sha":"19e2e9b4ef76b422ce1154af39a91323ccc57434","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3","authored_date":"2015-11-13T05:23:14.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T05:23:14.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":14,"relative_order":10,"sha":"c642fe9b8b9f28f9225d7ea953fe14e74748d53b","message":"add whitespace in empty\n","authored_date":"2015-11-13T05:08:45.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:45.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":14,"relative_order":11,"sha":"9a944d90955aaf45f6d0c88f30e27f8d2c41cec0","message":"add empty file\n","authored_date":"2015-11-13T05:08:04.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:04.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":14,"relative_order":12,"sha":"c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd","message":"Add ISO-8859 test file\n","authored_date":"2015-08-25T17:53:12.000+02:00","author_name":"Stan Hu","author_email":"stanhu@packetzoom.com","committed_date":"2015-08-25T17:53:12.000+02:00","committer_name":"Stan Hu","committer_email":"stanhu@packetzoom.com"},{"merge_request_diff_id":14,"relative_order":13,"sha":"e56497bb5f03a90a51293fc6d516788730953899","message":"Merge branch 'tree_helper_spec' into 'master'\n\nAdd directory structure for tree_helper spec\n\nThis directory structure is needed for a testing the method flatten_tree(tree) in the TreeHelper module\n\nSee [merge request #275](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/275#note_732774)\n\nSee merge request !2\n","authored_date":"2015-01-10T22:23:29.000+01:00","author_name":"Sytse Sijbrandij","author_email":"sytse@gitlab.com","committed_date":"2015-01-10T22:23:29.000+01:00","committer_name":"Sytse Sijbrandij","committer_email":"sytse@gitlab.com"},{"merge_request_diff_id":14,"relative_order":14,"sha":"4cd80ccab63c82b4bad16faa5193fbd2aa06df40","message":"add directory structure for tree_helper spec\n","authored_date":"2015-01-10T21:28:18.000+01:00","author_name":"marmis85","author_email":"marmis85@gmail.com","committed_date":"2015-01-10T21:28:18.000+01:00","committer_name":"marmis85","committer_email":"marmis85@gmail.com"},{"merge_request_diff_id":14,"relative_order":15,"sha":"5937ac0a7beb003549fc5fd26fc247adbce4a52e","message":"Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T10:01:38.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T10:01:38.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"},{"merge_request_diff_id":14,"relative_order":16,"sha":"570e7b2abdd848b95f2f578043fc23bd6f6fd24d","message":"Change some files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T09:57:31.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:57:31.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"},{"merge_request_diff_id":14,"relative_order":17,"sha":"6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9","message":"More submodules\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T09:54:21.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:54:21.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"},{"merge_request_diff_id":14,"relative_order":18,"sha":"d14d6c0abdd253381df51a723d58691b2ee1ab08","message":"Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T09:49:50.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:49:50.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"},{"merge_request_diff_id":14,"relative_order":19,"sha":"c1acaa58bbcbc3eafe538cb8274ba387047b69f8","message":"Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T09:48:32.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:48:32.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"}],"merge_request_diff_files":[{"merge_request_diff_id":14,"relative_order":0,"utf8_diff":"Binary files a/.DS_Store and /dev/null differ\n","new_path":".DS_Store","old_path":".DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":14,"relative_order":1,"utf8_diff":"--- a/.gitignore\n+++ b/.gitignore\n@@ -17,3 +17,4 @@ rerun.txt\n pickle-email-*.html\n .project\n config/initializers/secret_token.rb\n+.DS_Store\n","new_path":".gitignore","old_path":".gitignore","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":2,"utf8_diff":"--- a/.gitmodules\n+++ b/.gitmodules\n@@ -1,3 +1,9 @@\n [submodule \"six\"]\n \tpath = six\n \turl = git://github.com/randx/six.git\n+[submodule \"gitlab-shell\"]\n+\tpath = gitlab-shell\n+\turl = https://github.com/gitlabhq/gitlab-shell.git\n+[submodule \"gitlab-grack\"]\n+\tpath = gitlab-grack\n+\turl = https://gitlab.com/gitlab-org/gitlab-grack.git\n","new_path":".gitmodules","old_path":".gitmodules","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":3,"utf8_diff":"--- a/CHANGELOG\n+++ b/CHANGELOG\n@@ -1,4 +1,6 @@\n-v 6.7.0\n+v6.8.0\n+\n+v6.7.0\n - Add support for Gemnasium as a Project Service (Olivier Gonzalez)\n - Add edit file button to MergeRequest diff\n - Public groups (Jason Hollingsworth)\n","new_path":"CHANGELOG","old_path":"CHANGELOG","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/encoding/iso8859.txt\n@@ -0,0 +1 @@\n+Äü\n","new_path":"encoding/iso8859.txt","old_path":"encoding/iso8859.txt","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":5,"utf8_diff":"Binary files a/files/.DS_Store and /dev/null differ\n","new_path":"files/.DS_Store","old_path":"files/.DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":14,"relative_order":6,"utf8_diff":"--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n+<svg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\">\n+ <!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch -->\n+ <title>wm</title>\n+ <desc>Created with Sketch.</desc>\n+ <defs>\n+ <path id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"></path>\n+ </defs>\n+ <g id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\">\n+ <path d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\">\n+ <g id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\">\n+ <g id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\">\n+ <path d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"></path>\n+ </g>\n+ <g id=\"g16\">\n+ <g id=\"g18-Clipped\">\n+ <mask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\">\n+ <use xlink:href=\"#path-1\"></use>\n+ </mask>\n+ <g id=\"path22\"></g>\n+ <g id=\"g18\" mask=\"url(#mask-2)\">\n+ <g transform=\"translate(382.736659, 312.879425)\">\n+ <g id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\">\n+ <path d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\">\n+ <path d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\">\n+ <path d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\">\n+ <path d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <path d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <path d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\">\n+ <path d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\">\n+ <path d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path54\"></g>\n+ </g>\n+ <g id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\">\n+ <path d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <g id=\"path62\"></g>\n+ </g>\n+ <g id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\">\n+ <path d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path70\"></g>\n+ </g>\n+ <g id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\">\n+ <path d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <path d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\">\n+ <path d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\">\n+ <path d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+</svg>\n\\ No newline at end of file\n","new_path":"files/images/wm.svg","old_path":"files/images/wm.svg","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":7,"utf8_diff":"--- /dev/null\n+++ b/files/lfs/lfs_object.iso\n@@ -0,0 +1,4 @@\n+version https://git-lfs.github.com/spec/v1\n+oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897\n+size 1575078\n+\n","new_path":"files/lfs/lfs_object.iso","old_path":"files/lfs/lfs_object.iso","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":8,"utf8_diff":"--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output << stdout.read\n @cmd_output << stderr.read\n","new_path":"files/ruby/popen.rb","old_path":"files/ruby/popen.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":9,"utf8_diff":"--- a/files/ruby/regex.rb\n+++ b/files/ruby/regex.rb\n@@ -19,14 +19,12 @@ module Gitlab\n end\n \n def archive_formats_regex\n- #|zip|tar| tar.gz | tar.bz2 |\n- /(zip|tar|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n+ /(zip|tar|7z|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n end\n \n def git_reference_regex\n # Valid git ref regex, see:\n # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html\n-\n %r{\n (?!\n (?# doesn't begins with)\n","new_path":"files/ruby/regex.rb","old_path":"files/ruby/regex.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":10,"utf8_diff":"--- /dev/null\n+++ b/files/whitespace\n@@ -0,0 +1 @@\n+test \n","new_path":"files/whitespace","old_path":"files/whitespace","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":11,"utf8_diff":"--- /dev/null\n+++ b/foo/bar/.gitkeep\n","new_path":"foo/bar/.gitkeep","old_path":"foo/bar/.gitkeep","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":12,"utf8_diff":"--- /dev/null\n+++ b/gitlab-grack\n@@ -0,0 +1 @@\n+Subproject commit 645f6c4c82fd3f5e06f67134450a570b795e55a6\n","new_path":"gitlab-grack","old_path":"gitlab-grack","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":13,"utf8_diff":"--- /dev/null\n+++ b/gitlab-shell\n@@ -0,0 +1 @@\n+Subproject commit 79bceae69cb5750d6567b223597999bfa91cb3b9\n","new_path":"gitlab-shell","old_path":"gitlab-shell","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":14,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":14,"created_at":"2016-06-14T15:02:24.770Z","updated_at":"2016-06-14T15:02:25.007Z","base_commit_sha":"ae73cb07c9eeaf35924a10f713b364d32b2dd34f","real_size":"15"},"events":[{"id":224,"target_type":"MergeRequest","target_id":14,"project_id":36,"created_at":"2016-06-14T15:02:25.113Z","updated_at":"2016-06-14T15:02:25.113Z","action":1,"author_id":1},{"id":174,"target_type":"MergeRequest","target_id":14,"project_id":5,"created_at":"2016-06-14T15:02:25.113Z","updated_at":"2016-06-14T15:02:25.113Z","action":1,"author_id":20}]}
+{"id":13,"target_branch":"improve/awesome","source_branch":"test-8","source_project_id":5,"author_id":16,"assignee_id":25,"title":"Voluptates consequatur eius nemo amet libero animi illum delectus tempore.","created_at":"2016-06-14T15:02:24.415Z","updated_at":"2016-06-14T15:02:59.958Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":5,"description":"Est eaque quasi qui qui. Similique voluptatem impedit iusto ratione reprehenderit. Itaque est illum ut nulla aut.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":793,"note":"In illum maxime aperiam nulla est aspernatur.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:59.782Z","updated_at":"2016-06-14T15:02:59.782Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[{"merge_request_diff_id":14,"id":529,"target_type":"Note","target_id":793,"project_id":4,"created_at":"2016-07-07T14:35:12.128Z","updated_at":"2016-07-07T14:35:12.128Z","action":6,"author_id":1}]},{"id":794,"note":"Enim quia perferendis cum distinctio tenetur optio voluptas veniam.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:59.807Z","updated_at":"2016-06-14T15:02:59.807Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":795,"note":"Dolor ad quia quis pariatur ducimus.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:59.831Z","updated_at":"2016-06-14T15:02:59.831Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":796,"note":"Et a odio voluptate aut.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:59.854Z","updated_at":"2016-06-14T15:02:59.854Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":797,"note":"Quis nihil temporibus voluptatum modi minima a ut.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:59.879Z","updated_at":"2016-06-14T15:02:59.879Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":798,"note":"Ut alias consequatur in nostrum.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:59.904Z","updated_at":"2016-06-14T15:02:59.904Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":799,"note":"Voluptatibus aperiam assumenda et neque sint libero.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:59.926Z","updated_at":"2016-06-14T15:02:59.926Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":800,"note":"Veritatis voluptatem dolor dolores magni quo ut ipsa fuga.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:59.956Z","updated_at":"2016-06-14T15:02:59.956Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":13,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":13,"relative_order":0,"sha":"0bfedc29d30280c7e8564e19f654584b459e5868","message":"fixes #10\n","authored_date":"2016-01-19T15:25:23.000+01:00","author_name":"James Lopez","author_email":"james@jameslopez.es","committed_date":"2016-01-19T15:25:23.000+01:00","committer_name":"James Lopez","committer_email":"james@jameslopez.es"},{"merge_request_diff_id":13,"relative_order":1,"sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","message":"Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6","authored_date":"2015-12-07T12:52:12.000+01:00","author_name":"Marin Jankovski","author_email":"marin@gitlab.com","committed_date":"2015-12-07T12:52:12.000+01:00","committer_name":"Marin Jankovski","committer_email":"marin@gitlab.com"},{"merge_request_diff_id":13,"relative_order":2,"sha":"048721d90c449b244b7b4c53a9186b04330174ec","message":"LFS object pointer.\n","authored_date":"2015-12-07T11:54:28.000+01:00","author_name":"Marin Jankovski","author_email":"maxlazio@gmail.com","committed_date":"2015-12-07T11:54:28.000+01:00","committer_name":"Marin Jankovski","committer_email":"maxlazio@gmail.com"},{"merge_request_diff_id":13,"relative_order":3,"sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","message":"GitLab currently doesn't support patches that involve a merge commit: add a commit here\n","authored_date":"2015-11-13T16:27:12.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T16:27:12.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":13,"relative_order":4,"sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","message":"Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5","authored_date":"2015-11-13T08:50:17.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:50:17.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":13,"relative_order":5,"sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","message":"Add GitLab SVG\n","authored_date":"2015-11-13T08:39:43.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:39:43.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":13,"relative_order":6,"sha":"59e29889be61e6e0e5e223bfa9ac2721d31605b8","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4","authored_date":"2015-11-13T07:21:40.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T07:21:40.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":13,"relative_order":7,"sha":"66eceea0db202bb39c4e445e8ca28689645366c5","message":"add spaces in whitespace file\n","authored_date":"2015-11-13T06:01:27.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:01:27.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":13,"relative_order":8,"sha":"08f22f255f082689c0d7d39d19205085311542bc","message":"remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n","authored_date":"2015-11-13T06:00:16.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:00:16.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":13,"relative_order":9,"sha":"19e2e9b4ef76b422ce1154af39a91323ccc57434","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3","authored_date":"2015-11-13T05:23:14.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T05:23:14.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":13,"relative_order":10,"sha":"c642fe9b8b9f28f9225d7ea953fe14e74748d53b","message":"add whitespace in empty\n","authored_date":"2015-11-13T05:08:45.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:45.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":13,"relative_order":11,"sha":"9a944d90955aaf45f6d0c88f30e27f8d2c41cec0","message":"add empty file\n","authored_date":"2015-11-13T05:08:04.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:04.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":13,"relative_order":12,"sha":"c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd","message":"Add ISO-8859 test file\n","authored_date":"2015-08-25T17:53:12.000+02:00","author_name":"Stan Hu","author_email":"stanhu@packetzoom.com","committed_date":"2015-08-25T17:53:12.000+02:00","committer_name":"Stan Hu","committer_email":"stanhu@packetzoom.com"},{"merge_request_diff_id":13,"relative_order":13,"sha":"e56497bb5f03a90a51293fc6d516788730953899","message":"Merge branch 'tree_helper_spec' into 'master'\n\nAdd directory structure for tree_helper spec\n\nThis directory structure is needed for a testing the method flatten_tree(tree) in the TreeHelper module\n\nSee [merge request #275](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/275#note_732774)\n\nSee merge request !2\n","authored_date":"2015-01-10T22:23:29.000+01:00","author_name":"Sytse Sijbrandij","author_email":"sytse@gitlab.com","committed_date":"2015-01-10T22:23:29.000+01:00","committer_name":"Sytse Sijbrandij","committer_email":"sytse@gitlab.com"},{"merge_request_diff_id":13,"relative_order":14,"sha":"4cd80ccab63c82b4bad16faa5193fbd2aa06df40","message":"add directory structure for tree_helper spec\n","authored_date":"2015-01-10T21:28:18.000+01:00","author_name":"marmis85","author_email":"marmis85@gmail.com","committed_date":"2015-01-10T21:28:18.000+01:00","committer_name":"marmis85","committer_email":"marmis85@gmail.com"}],"merge_request_diff_files":[{"merge_request_diff_id":13,"relative_order":0,"utf8_diff":"--- a/CHANGELOG\n+++ b/CHANGELOG\n@@ -1,4 +1,6 @@\n-v 6.7.0\n+v6.8.0\n+\n+v6.7.0\n - Add support for Gemnasium as a Project Service (Olivier Gonzalez)\n - Add edit file button to MergeRequest diff\n - Public groups (Jason Hollingsworth)\n","new_path":"CHANGELOG","old_path":"CHANGELOG","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":1,"utf8_diff":"--- /dev/null\n+++ b/encoding/iso8859.txt\n@@ -0,0 +1 @@\n+Äü\n","new_path":"encoding/iso8859.txt","old_path":"encoding/iso8859.txt","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":2,"utf8_diff":"--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n+<svg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\">\n+ <!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch -->\n+ <title>wm</title>\n+ <desc>Created with Sketch.</desc>\n+ <defs>\n+ <path id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"></path>\n+ </defs>\n+ <g id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\">\n+ <path d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\">\n+ <g id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\">\n+ <g id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\">\n+ <path d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"></path>\n+ </g>\n+ <g id=\"g16\">\n+ <g id=\"g18-Clipped\">\n+ <mask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\">\n+ <use xlink:href=\"#path-1\"></use>\n+ </mask>\n+ <g id=\"path22\"></g>\n+ <g id=\"g18\" mask=\"url(#mask-2)\">\n+ <g transform=\"translate(382.736659, 312.879425)\">\n+ <g id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\">\n+ <path d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\">\n+ <path d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\">\n+ <path d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\">\n+ <path d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <path d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <path d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\">\n+ <path d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\">\n+ <path d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path54\"></g>\n+ </g>\n+ <g id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\">\n+ <path d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <g id=\"path62\"></g>\n+ </g>\n+ <g id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\">\n+ <path d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path70\"></g>\n+ </g>\n+ <g id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\">\n+ <path d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <path d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\">\n+ <path d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\">\n+ <path d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+</svg>\n\\ No newline at end of file\n","new_path":"files/images/wm.svg","old_path":"files/images/wm.svg","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":3,"utf8_diff":"--- /dev/null\n+++ b/files/lfs/lfs_object.iso\n@@ -0,0 +1,4 @@\n+version https://git-lfs.github.com/spec/v1\n+oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897\n+size 1575078\n+\n","new_path":"files/lfs/lfs_object.iso","old_path":"files/lfs/lfs_object.iso","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/files/whitespace\n@@ -0,0 +1 @@\n+test \n","new_path":"files/whitespace","old_path":"files/whitespace","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":5,"utf8_diff":"--- /dev/null\n+++ b/foo/bar/.gitkeep\n","new_path":"foo/bar/.gitkeep","old_path":"foo/bar/.gitkeep","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":6,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":13,"created_at":"2016-06-14T15:02:24.420Z","updated_at":"2016-06-14T15:02:24.561Z","base_commit_sha":"5937ac0a7beb003549fc5fd26fc247adbce4a52e","real_size":"7"},"events":[{"id":225,"target_type":"MergeRequest","target_id":13,"project_id":36,"created_at":"2016-06-14T15:02:24.636Z","updated_at":"2016-06-14T15:02:24.636Z","action":1,"author_id":16},{"id":173,"target_type":"MergeRequest","target_id":13,"project_id":5,"created_at":"2016-06-14T15:02:24.636Z","updated_at":"2016-06-14T15:02:24.636Z","action":1,"author_id":16}]}
+{"id":12,"target_branch":"flatten-dirs","source_branch":"test-2","source_project_id":5,"author_id":1,"assignee_id":22,"title":"In a rerum harum nihil accusamus aut quia nobis non.","created_at":"2016-06-14T15:02:24.000Z","updated_at":"2016-06-14T15:03:00.225Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":4,"description":"Nam magnam odit velit rerum. Sapiente dolore sunt saepe debitis. Culpa maiores ut ad dolores dolorem et.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":801,"note":"Nihil dicta molestias expedita atque.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:03:00.001Z","updated_at":"2016-06-14T15:03:00.001Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":802,"note":"Illum culpa voluptas enim accusantium deserunt.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:03:00.034Z","updated_at":"2016-06-14T15:03:00.034Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":803,"note":"Dicta esse aliquam laboriosam unde alias.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:03:00.065Z","updated_at":"2016-06-14T15:03:00.065Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":804,"note":"Dicta autem et sed molestiae ut quae.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:03:00.097Z","updated_at":"2016-06-14T15:03:00.097Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":805,"note":"Ut ut temporibus voluptas dolore quia velit.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:03:00.129Z","updated_at":"2016-06-14T15:03:00.129Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":806,"note":"Dolores similique sint pariatur error id quia fugit aut.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:03:00.162Z","updated_at":"2016-06-14T15:03:00.162Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":807,"note":"Quisquam provident nihil aperiam voluptatem.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:03:00.193Z","updated_at":"2016-06-14T15:03:00.193Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":808,"note":"Similique quo vero expedita deserunt ipsam earum.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:03:00.224Z","updated_at":"2016-06-14T15:03:00.224Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":12,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":12,"relative_order":0,"sha":"97a0df9696e2aebf10c31b3016f40214e0e8f243","message":"fixes #10\n","authored_date":"2016-01-19T14:08:21.000+01:00","author_name":"James Lopez","author_email":"james@jameslopez.es","committed_date":"2016-01-19T14:08:21.000+01:00","committer_name":"James Lopez","committer_email":"james@jameslopez.es"},{"merge_request_diff_id":12,"relative_order":1,"sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","message":"Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6","authored_date":"2015-12-07T12:52:12.000+01:00","author_name":"Marin Jankovski","author_email":"marin@gitlab.com","committed_date":"2015-12-07T12:52:12.000+01:00","committer_name":"Marin Jankovski","committer_email":"marin@gitlab.com"},{"merge_request_diff_id":12,"relative_order":2,"sha":"048721d90c449b244b7b4c53a9186b04330174ec","message":"LFS object pointer.\n","authored_date":"2015-12-07T11:54:28.000+01:00","author_name":"Marin Jankovski","author_email":"maxlazio@gmail.com","committed_date":"2015-12-07T11:54:28.000+01:00","committer_name":"Marin Jankovski","committer_email":"maxlazio@gmail.com"},{"merge_request_diff_id":12,"relative_order":3,"sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","message":"GitLab currently doesn't support patches that involve a merge commit: add a commit here\n","authored_date":"2015-11-13T16:27:12.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T16:27:12.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":12,"relative_order":4,"sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","message":"Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5","authored_date":"2015-11-13T08:50:17.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:50:17.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":12,"relative_order":5,"sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","message":"Add GitLab SVG\n","authored_date":"2015-11-13T08:39:43.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:39:43.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":12,"relative_order":6,"sha":"59e29889be61e6e0e5e223bfa9ac2721d31605b8","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4","authored_date":"2015-11-13T07:21:40.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T07:21:40.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":12,"relative_order":7,"sha":"66eceea0db202bb39c4e445e8ca28689645366c5","message":"add spaces in whitespace file\n","authored_date":"2015-11-13T06:01:27.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:01:27.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":12,"relative_order":8,"sha":"08f22f255f082689c0d7d39d19205085311542bc","message":"remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n","authored_date":"2015-11-13T06:00:16.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:00:16.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":12,"relative_order":9,"sha":"19e2e9b4ef76b422ce1154af39a91323ccc57434","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3","authored_date":"2015-11-13T05:23:14.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T05:23:14.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":12,"relative_order":10,"sha":"c642fe9b8b9f28f9225d7ea953fe14e74748d53b","message":"add whitespace in empty\n","authored_date":"2015-11-13T05:08:45.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:45.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":12,"relative_order":11,"sha":"9a944d90955aaf45f6d0c88f30e27f8d2c41cec0","message":"add empty file\n","authored_date":"2015-11-13T05:08:04.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:04.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":12,"relative_order":12,"sha":"c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd","message":"Add ISO-8859 test file\n","authored_date":"2015-08-25T17:53:12.000+02:00","author_name":"Stan Hu","author_email":"stanhu@packetzoom.com","committed_date":"2015-08-25T17:53:12.000+02:00","committer_name":"Stan Hu","committer_email":"stanhu@packetzoom.com"}],"merge_request_diff_files":[{"merge_request_diff_id":12,"relative_order":0,"utf8_diff":"--- a/CHANGELOG\n+++ b/CHANGELOG\n@@ -1,4 +1,6 @@\n-v 6.7.0\n+v6.8.0\n+\n+v6.7.0\n - Add support for Gemnasium as a Project Service (Olivier Gonzalez)\n - Add edit file button to MergeRequest diff\n - Public groups (Jason Hollingsworth)\n","new_path":"CHANGELOG","old_path":"CHANGELOG","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":1,"utf8_diff":"--- /dev/null\n+++ b/encoding/iso8859.txt\n@@ -0,0 +1 @@\n+Äü\n","new_path":"encoding/iso8859.txt","old_path":"encoding/iso8859.txt","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":2,"utf8_diff":"--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n+<svg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\">\n+ <!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch -->\n+ <title>wm</title>\n+ <desc>Created with Sketch.</desc>\n+ <defs>\n+ <path id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"></path>\n+ </defs>\n+ <g id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\">\n+ <path d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\">\n+ <g id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\">\n+ <g id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\">\n+ <path d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"></path>\n+ </g>\n+ <g id=\"g16\">\n+ <g id=\"g18-Clipped\">\n+ <mask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\">\n+ <use xlink:href=\"#path-1\"></use>\n+ </mask>\n+ <g id=\"path22\"></g>\n+ <g id=\"g18\" mask=\"url(#mask-2)\">\n+ <g transform=\"translate(382.736659, 312.879425)\">\n+ <g id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\">\n+ <path d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\">\n+ <path d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\">\n+ <path d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\">\n+ <path d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <path d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <path d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\">\n+ <path d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\">\n+ <path d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path54\"></g>\n+ </g>\n+ <g id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\">\n+ <path d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <g id=\"path62\"></g>\n+ </g>\n+ <g id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\">\n+ <path d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path70\"></g>\n+ </g>\n+ <g id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\">\n+ <path d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <path d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\">\n+ <path d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\">\n+ <path d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+</svg>\n\\ No newline at end of file\n","new_path":"files/images/wm.svg","old_path":"files/images/wm.svg","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":3,"utf8_diff":"--- /dev/null\n+++ b/files/lfs/lfs_object.iso\n@@ -0,0 +1,4 @@\n+version https://git-lfs.github.com/spec/v1\n+oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897\n+size 1575078\n+\n","new_path":"files/lfs/lfs_object.iso","old_path":"files/lfs/lfs_object.iso","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/files/whitespace\n@@ -0,0 +1 @@\n+test \n","new_path":"files/whitespace","old_path":"files/whitespace","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":5,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":12,"created_at":"2016-06-14T15:02:24.006Z","updated_at":"2016-06-14T15:02:24.169Z","base_commit_sha":"e56497bb5f03a90a51293fc6d516788730953899","real_size":"6"},"events":[{"id":226,"target_type":"MergeRequest","target_id":12,"project_id":36,"created_at":"2016-06-14T15:02:24.253Z","updated_at":"2016-06-14T15:02:24.253Z","action":1,"author_id":1},{"id":172,"target_type":"MergeRequest","target_id":12,"project_id":5,"created_at":"2016-06-14T15:02:24.253Z","updated_at":"2016-06-14T15:02:24.253Z","action":1,"author_id":1}]}
+{"id":11,"target_branch":"test-15","source_branch":"'test'","source_project_id":5,"author_id":16,"assignee_id":16,"title":"Corporis provident similique perspiciatis dolores eos animi.","created_at":"2016-06-14T15:02:23.767Z","updated_at":"2016-06-14T15:03:00.475Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":3,"description":"Libero nesciunt mollitia quis odit eos vero quasi. Iure voluptatem ut sint pariatur voluptates ut aut. Laborum possimus unde illum ipsum eum.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":809,"note":"Omnis ratione laboriosam dolores qui.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:03:00.260Z","updated_at":"2016-06-14T15:03:00.260Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":810,"note":"Voluptas voluptates pariatur dolores maxime est voluptas.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:03:00.290Z","updated_at":"2016-06-14T15:03:00.290Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":811,"note":"Sit perspiciatis facilis ipsum consequatur.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:03:00.323Z","updated_at":"2016-06-14T15:03:00.323Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":812,"note":"Ut neque aliquam nam et est.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:03:00.349Z","updated_at":"2016-06-14T15:03:00.349Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":813,"note":"Et debitis rerum minima sit aut dolorem.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:03:00.374Z","updated_at":"2016-06-14T15:03:00.374Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":814,"note":"Ea nisi earum fugit iste aperiam consequatur.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:03:00.397Z","updated_at":"2016-06-14T15:03:00.397Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":815,"note":"Amet ratione consequatur laudantium rerum voluptas est nobis.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:03:00.450Z","updated_at":"2016-06-14T15:03:00.450Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":816,"note":"Ab ducimus cumque quia dolorem vitae sint beatae rerum.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:03:00.474Z","updated_at":"2016-06-14T15:03:00.474Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":11,"state":"empty","merge_request_diff_commits":[],"merge_request_diff_files":[],"merge_request_id":11,"created_at":"2016-06-14T15:02:23.772Z","updated_at":"2016-06-14T15:02:23.833Z","base_commit_sha":"e56497bb5f03a90a51293fc6d516788730953899","real_size":null},"events":[{"id":227,"target_type":"MergeRequest","target_id":11,"project_id":36,"created_at":"2016-06-14T15:02:23.865Z","updated_at":"2016-06-14T15:02:23.865Z","action":1,"author_id":16},{"id":171,"target_type":"MergeRequest","target_id":11,"project_id":5,"created_at":"2016-06-14T15:02:23.865Z","updated_at":"2016-06-14T15:02:23.865Z","action":1,"author_id":16}]}
+{"id":10,"target_branch":"feature","source_branch":"test-5","source_project_id":5,"author_id":20,"assignee_id":25,"title":"Eligendi reprehenderit doloribus quia et sit id.","created_at":"2016-06-14T15:02:23.014Z","updated_at":"2016-06-14T15:03:00.685Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":2,"description":"Ut dolor quia aliquid dolore et nisi. Est minus suscipit enim quaerat sapiente consequatur rerum. Eveniet provident consequatur dolor accusantium reiciendis.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":817,"note":"Recusandae et voluptas enim qui et.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:03:00.510Z","updated_at":"2016-06-14T15:03:00.510Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":818,"note":"Asperiores dolorem rerum ipsum totam.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:03:00.538Z","updated_at":"2016-06-14T15:03:00.538Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":819,"note":"Qui quam et iure quasi provident cumque itaque sequi.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:03:00.562Z","updated_at":"2016-06-14T15:03:00.562Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":820,"note":"Sint accusantium aliquid iste qui iusto minus vel.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:03:00.585Z","updated_at":"2016-06-14T15:03:00.585Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":821,"note":"Dolor corrupti dolorem blanditiis voluptas.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:03:00.610Z","updated_at":"2016-06-14T15:03:00.610Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":822,"note":"Est perferendis assumenda aliquam aliquid sit ipsum ullam aut.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:03:00.635Z","updated_at":"2016-06-14T15:03:00.635Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":823,"note":"Hic neque reiciendis quaerat maiores.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:03:00.659Z","updated_at":"2016-06-14T15:03:00.659Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":824,"note":"Sequi architecto doloribus ut vel autem.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:03:00.683Z","updated_at":"2016-06-14T15:03:00.683Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":10,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":10,"relative_order":0,"sha":"f998ac87ac9244f15e9c15109a6f4e62a54b779d","message":"fixes #10\n","authored_date":"2016-01-19T14:43:23.000+01:00","author_name":"James Lopez","author_email":"james@jameslopez.es","committed_date":"2016-01-19T14:43:23.000+01:00","committer_name":"James Lopez","committer_email":"james@jameslopez.es"},{"merge_request_diff_id":10,"relative_order":1,"sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","message":"Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6","authored_date":"2015-12-07T12:52:12.000+01:00","author_name":"Marin Jankovski","author_email":"marin@gitlab.com","committed_date":"2015-12-07T12:52:12.000+01:00","committer_name":"Marin Jankovski","committer_email":"marin@gitlab.com"},{"merge_request_diff_id":10,"relative_order":2,"sha":"048721d90c449b244b7b4c53a9186b04330174ec","message":"LFS object pointer.\n","authored_date":"2015-12-07T11:54:28.000+01:00","author_name":"Marin Jankovski","author_email":"maxlazio@gmail.com","committed_date":"2015-12-07T11:54:28.000+01:00","committer_name":"Marin Jankovski","committer_email":"maxlazio@gmail.com"},{"merge_request_diff_id":10,"relative_order":3,"sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","message":"GitLab currently doesn't support patches that involve a merge commit: add a commit here\n","authored_date":"2015-11-13T16:27:12.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T16:27:12.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":10,"relative_order":4,"sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","message":"Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5","authored_date":"2015-11-13T08:50:17.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:50:17.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":10,"relative_order":5,"sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","message":"Add GitLab SVG\n","authored_date":"2015-11-13T08:39:43.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:39:43.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":10,"relative_order":6,"sha":"59e29889be61e6e0e5e223bfa9ac2721d31605b8","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4","authored_date":"2015-11-13T07:21:40.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T07:21:40.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":10,"relative_order":7,"sha":"66eceea0db202bb39c4e445e8ca28689645366c5","message":"add spaces in whitespace file\n","authored_date":"2015-11-13T06:01:27.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:01:27.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":10,"relative_order":8,"sha":"08f22f255f082689c0d7d39d19205085311542bc","message":"remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n","authored_date":"2015-11-13T06:00:16.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:00:16.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":10,"relative_order":9,"sha":"19e2e9b4ef76b422ce1154af39a91323ccc57434","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3","authored_date":"2015-11-13T05:23:14.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T05:23:14.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com"},{"merge_request_diff_id":10,"relative_order":10,"sha":"c642fe9b8b9f28f9225d7ea953fe14e74748d53b","message":"add whitespace in empty\n","authored_date":"2015-11-13T05:08:45.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:45.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":10,"relative_order":11,"sha":"9a944d90955aaf45f6d0c88f30e27f8d2c41cec0","message":"add empty file\n","authored_date":"2015-11-13T05:08:04.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:04.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com"},{"merge_request_diff_id":10,"relative_order":12,"sha":"c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd","message":"Add ISO-8859 test file\n","authored_date":"2015-08-25T17:53:12.000+02:00","author_name":"Stan Hu","author_email":"stanhu@packetzoom.com","committed_date":"2015-08-25T17:53:12.000+02:00","committer_name":"Stan Hu","committer_email":"stanhu@packetzoom.com"},{"merge_request_diff_id":10,"relative_order":13,"sha":"e56497bb5f03a90a51293fc6d516788730953899","message":"Merge branch 'tree_helper_spec' into 'master'\n\nAdd directory structure for tree_helper spec\n\nThis directory structure is needed for a testing the method flatten_tree(tree) in the TreeHelper module\n\nSee [merge request #275](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/275#note_732774)\n\nSee merge request !2\n","authored_date":"2015-01-10T22:23:29.000+01:00","author_name":"Sytse Sijbrandij","author_email":"sytse@gitlab.com","committed_date":"2015-01-10T22:23:29.000+01:00","committer_name":"Sytse Sijbrandij","committer_email":"sytse@gitlab.com"},{"merge_request_diff_id":10,"relative_order":14,"sha":"4cd80ccab63c82b4bad16faa5193fbd2aa06df40","message":"add directory structure for tree_helper spec\n","authored_date":"2015-01-10T21:28:18.000+01:00","author_name":"marmis85","author_email":"marmis85@gmail.com","committed_date":"2015-01-10T21:28:18.000+01:00","committer_name":"marmis85","committer_email":"marmis85@gmail.com"},{"merge_request_diff_id":10,"relative_order":16,"sha":"5937ac0a7beb003549fc5fd26fc247adbce4a52e","message":"Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T10:01:38.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T10:01:38.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"},{"merge_request_diff_id":10,"relative_order":17,"sha":"570e7b2abdd848b95f2f578043fc23bd6f6fd24d","message":"Change some files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T09:57:31.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:57:31.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"},{"merge_request_diff_id":10,"relative_order":18,"sha":"6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9","message":"More submodules\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T09:54:21.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:54:21.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"},{"merge_request_diff_id":10,"relative_order":19,"sha":"d14d6c0abdd253381df51a723d58691b2ee1ab08","message":"Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T09:49:50.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:49:50.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"},{"merge_request_diff_id":10,"relative_order":20,"sha":"c1acaa58bbcbc3eafe538cb8274ba387047b69f8","message":"Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n","authored_date":"2014-02-27T09:48:32.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:48:32.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com"}],"merge_request_diff_files":[{"merge_request_diff_id":10,"relative_order":0,"utf8_diff":"Binary files a/.DS_Store and /dev/null differ\n","new_path":".DS_Store","old_path":".DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":10,"relative_order":1,"utf8_diff":"--- a/.gitignore\n+++ b/.gitignore\n@@ -17,3 +17,4 @@ rerun.txt\n pickle-email-*.html\n .project\n config/initializers/secret_token.rb\n+.DS_Store\n","new_path":".gitignore","old_path":".gitignore","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":2,"utf8_diff":"--- a/.gitmodules\n+++ b/.gitmodules\n@@ -1,3 +1,9 @@\n [submodule \"six\"]\n \tpath = six\n \turl = git://github.com/randx/six.git\n+[submodule \"gitlab-shell\"]\n+\tpath = gitlab-shell\n+\turl = https://github.com/gitlabhq/gitlab-shell.git\n+[submodule \"gitlab-grack\"]\n+\tpath = gitlab-grack\n+\turl = https://gitlab.com/gitlab-org/gitlab-grack.git\n","new_path":".gitmodules","old_path":".gitmodules","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":3,"utf8_diff":"--- a/CHANGELOG\n+++ b/CHANGELOG\n@@ -1,4 +1,6 @@\n-v 6.7.0\n+v6.8.0\n+\n+v6.7.0\n - Add support for Gemnasium as a Project Service (Olivier Gonzalez)\n - Add edit file button to MergeRequest diff\n - Public groups (Jason Hollingsworth)\n","new_path":"CHANGELOG","old_path":"CHANGELOG","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/encoding/iso8859.txt\n@@ -0,0 +1 @@\n+Äü\n","new_path":"encoding/iso8859.txt","old_path":"encoding/iso8859.txt","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":5,"utf8_diff":"Binary files a/files/.DS_Store and /dev/null differ\n","new_path":"files/.DS_Store","old_path":"files/.DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":10,"relative_order":6,"utf8_diff":"--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n+<svg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\">\n+ <!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch -->\n+ <title>wm</title>\n+ <desc>Created with Sketch.</desc>\n+ <defs>\n+ <path id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"></path>\n+ </defs>\n+ <g id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\">\n+ <path d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\">\n+ <g id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\">\n+ <g id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\">\n+ <path d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"></path>\n+ </g>\n+ <g id=\"g16\">\n+ <g id=\"g18-Clipped\">\n+ <mask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\">\n+ <use xlink:href=\"#path-1\"></use>\n+ </mask>\n+ <g id=\"path22\"></g>\n+ <g id=\"g18\" mask=\"url(#mask-2)\">\n+ <g transform=\"translate(382.736659, 312.879425)\">\n+ <g id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\">\n+ <path d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\">\n+ <path d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\">\n+ <path d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\">\n+ <path d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <path d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <path d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\">\n+ <path d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\">\n+ <path d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path54\"></g>\n+ </g>\n+ <g id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\">\n+ <path d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <g id=\"path62\"></g>\n+ </g>\n+ <g id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\">\n+ <path d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path70\"></g>\n+ </g>\n+ <g id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\">\n+ <path d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <path d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\">\n+ <path d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\">\n+ <path d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+</svg>\n\\ No newline at end of file\n","new_path":"files/images/wm.svg","old_path":"files/images/wm.svg","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":7,"utf8_diff":"--- /dev/null\n+++ b/files/lfs/lfs_object.iso\n@@ -0,0 +1,4 @@\n+version https://git-lfs.github.com/spec/v1\n+oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897\n+size 1575078\n+\n","new_path":"files/lfs/lfs_object.iso","old_path":"files/lfs/lfs_object.iso","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":8,"utf8_diff":"--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output << stdout.read\n @cmd_output << stderr.read\n","new_path":"files/ruby/popen.rb","old_path":"files/ruby/popen.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":9,"utf8_diff":"--- a/files/ruby/regex.rb\n+++ b/files/ruby/regex.rb\n@@ -19,14 +19,12 @@ module Gitlab\n end\n \n def archive_formats_regex\n- #|zip|tar| tar.gz | tar.bz2 |\n- /(zip|tar|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n+ /(zip|tar|7z|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n end\n \n def git_reference_regex\n # Valid git ref regex, see:\n # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html\n-\n %r{\n (?!\n (?# doesn't begins with)\n","new_path":"files/ruby/regex.rb","old_path":"files/ruby/regex.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":10,"utf8_diff":"--- /dev/null\n+++ b/files/whitespace\n@@ -0,0 +1 @@\n+test \n","new_path":"files/whitespace","old_path":"files/whitespace","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":11,"utf8_diff":"--- /dev/null\n+++ b/foo/bar/.gitkeep\n","new_path":"foo/bar/.gitkeep","old_path":"foo/bar/.gitkeep","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":12,"utf8_diff":"--- /dev/null\n+++ b/gitlab-grack\n@@ -0,0 +1 @@\n+Subproject commit 645f6c4c82fd3f5e06f67134450a570b795e55a6\n","new_path":"gitlab-grack","old_path":"gitlab-grack","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":13,"utf8_diff":"--- /dev/null\n+++ b/gitlab-shell\n@@ -0,0 +1 @@\n+Subproject commit 79bceae69cb5750d6567b223597999bfa91cb3b9\n","new_path":"gitlab-shell","old_path":"gitlab-shell","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":14,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":10,"created_at":"2016-06-14T15:02:23.019Z","updated_at":"2016-06-14T15:02:23.493Z","base_commit_sha":"ae73cb07c9eeaf35924a10f713b364d32b2dd34f","real_size":"15"},"events":[{"id":228,"target_type":"MergeRequest","target_id":10,"project_id":36,"created_at":"2016-06-14T15:02:23.660Z","updated_at":"2016-06-14T15:02:23.660Z","action":1,"author_id":1},{"id":170,"target_type":"MergeRequest","target_id":10,"project_id":5,"created_at":"2016-06-14T15:02:23.660Z","updated_at":"2016-06-14T15:02:23.660Z","action":1,"author_id":20}]}
+{"id":9,"target_branch":"test-6","source_branch":"test-12","source_project_id":5,"author_id":16,"assignee_id":6,"title":"Et ipsam voluptas velit sequi illum ut.","created_at":"2016-06-14T15:02:22.825Z","updated_at":"2016-06-14T15:03:00.904Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":1,"description":"Eveniet nihil ratione veniam similique qui aut sapiente tempora. Sed praesentium iusto dignissimos possimus id repudiandae quo nihil. Qui doloremque autem et iure fugit.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":825,"note":"Aliquid voluptatem consequatur voluptas ex perspiciatis.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:03:00.722Z","updated_at":"2016-06-14T15:03:00.722Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":826,"note":"Itaque optio voluptatem praesentium voluptas.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:03:00.745Z","updated_at":"2016-06-14T15:03:00.745Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":827,"note":"Ut est corporis fuga asperiores delectus excepturi aperiam.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:03:00.771Z","updated_at":"2016-06-14T15:03:00.771Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":828,"note":"Similique ea dolore officiis temporibus.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:03:00.798Z","updated_at":"2016-06-14T15:03:00.798Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":829,"note":"Qui laudantium qui quae quis.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:03:00.828Z","updated_at":"2016-06-14T15:03:00.828Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":830,"note":"Et vel voluptas amet laborum qui soluta.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:03:00.850Z","updated_at":"2016-06-14T15:03:00.850Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":831,"note":"Enim ad consequuntur assumenda provident voluptatem similique deleniti.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:03:00.876Z","updated_at":"2016-06-14T15:03:00.876Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":832,"note":"Officiis sequi commodi pariatur totam fugiat voluptas corporis dignissimos.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:03:00.902Z","updated_at":"2016-06-14T15:03:00.902Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":9,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":9,"relative_order":0,"sha":"a4e5dfebf42e34596526acb8611bc7ed80e4eb3f","message":"fixes #10\n","authored_date":"2016-01-19T15:44:02.000+01:00","author_name":"James Lopez","author_email":"james@jameslopez.es","committed_date":"2016-01-19T15:44:02.000+01:00","committer_name":"James Lopez","committer_email":"james@jameslopez.es"}],"merge_request_diff_files":[{"merge_request_diff_id":9,"relative_order":0,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":9,"created_at":"2016-06-14T15:02:22.829Z","updated_at":"2016-06-14T15:02:22.900Z","base_commit_sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","real_size":"1"},"events":[{"id":229,"target_type":"MergeRequest","target_id":9,"project_id":36,"created_at":"2016-06-14T15:02:22.927Z","updated_at":"2016-06-14T15:02:22.927Z","action":1,"author_id":16},{"id":169,"target_type":"MergeRequest","target_id":9,"project_id":5,"created_at":"2016-06-14T15:02:22.927Z","updated_at":"2016-06-14T15:02:22.927Z","action":1,"author_id":16}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/milestones.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/milestones.ndjson
new file mode 100644
index 00000000000..2c9a5b00eb4
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/milestones.ndjson
@@ -0,0 +1,3 @@
+{"id":1,"title":"test milestone","project_id":8,"description":"test milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"events":[{"id":487,"target_type":"Milestone","target_id":1,"project_id":46,"created_at":"2016-06-14T15:02:04.418Z","updated_at":"2016-06-14T15:02:04.418Z","action":1,"author_id":18}]}
+{"id":20,"title":"v4.0","project_id":5,"description":"Totam quam laborum id magnam natus eaque aspernatur.","due_date":null,"created_at":"2016-06-14T15:02:04.590Z","updated_at":"2016-06-14T15:02:04.590Z","state":"active","iid":5,"events":[{"id":240,"target_type":"Milestone","target_id":20,"project_id":36,"created_at":"2016-06-14T15:02:04.593Z","updated_at":"2016-06-14T15:02:04.593Z","action":1,"author_id":1},{"id":60,"target_type":"Milestone","target_id":20,"project_id":5,"created_at":"2016-06-14T15:02:04.593Z","updated_at":"2016-06-14T15:02:04.593Z","action":1,"author_id":20}]}
+{"id":19,"title":"v3.0","project_id":5,"description":"Rerum at autem exercitationem ea voluptates harum quam placeat.","due_date":null,"created_at":"2016-06-14T15:02:04.583Z","updated_at":"2016-06-14T15:02:04.583Z","state":"active","iid":4,"events":[{"id":241,"target_type":"Milestone","target_id":19,"project_id":36,"created_at":"2016-06-14T15:02:04.585Z","updated_at":"2016-06-14T15:02:04.585Z","action":1,"author_id":1},{"id":59,"target_type":"Milestone","target_id":19,"project_id":5,"created_at":"2016-06-14T15:02:04.585Z","updated_at":"2016-06-14T15:02:04.585Z","action":1,"author_id":25}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/pipeline_schedules.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/pipeline_schedules.ndjson
new file mode 100644
index 00000000000..6d429be9f51
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/pipeline_schedules.ndjson
@@ -0,0 +1 @@
+{"id":1,"description":"Schedule Description","ref":"master","cron":"0 4 * * 0","cron_timezone":"UTC","next_run_at":"2019-12-29T04:19:00.000Z","project_id":5,"owner_id":1,"active":true,"created_at":"2019-12-26T10:14:57.778Z","updated_at":"2019-12-26T10:14:57.778Z"}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/project_badges.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/project_badges.ndjson
new file mode 100644
index 00000000000..f84305f3c0c
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/project_badges.ndjson
@@ -0,0 +1,2 @@
+{"id":1,"created_at":"2017-10-19T15:36:23.466Z","updated_at":"2017-10-19T15:36:23.466Z","project_id":5,"type":"ProjectBadge","link_url":"http://www.example.com","image_url":"http://www.example.com"}
+{"id":2,"created_at":"2017-10-19T15:36:23.466Z","updated_at":"2017-10-19T15:36:23.466Z","project_id":5,"type":"ProjectBadge","link_url":"http://www.example.com","image_url":"http://www.example.com"}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/project_feature.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/project_feature.ndjson
new file mode 100644
index 00000000000..a349dc6722e
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/project_feature.ndjson
@@ -0,0 +1 @@
+{"builds_access_level":10,"created_at":"2014-12-26T09:26:45.000Z","id":2,"issues_access_level":10,"merge_requests_access_level":10,"project_id":4,"snippets_access_level":10,"updated_at":"2016-09-23T11:58:28.000Z","wiki_access_level":10}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/project_members.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/project_members.ndjson
new file mode 100644
index 00000000000..d8be7b5d164
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/project_members.ndjson
@@ -0,0 +1,4 @@
+{"id":36,"access_level":40,"source_id":5,"source_type":"Project","user_id":16,"notification_level":3,"created_at":"2016-06-14T15:02:03.834Z","updated_at":"2016-06-14T15:02:03.834Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"user":{"id":16,"email":"bernard_willms@gitlabexample.com","username":"bernard_willms"}}
+{"id":35,"access_level":10,"source_id":5,"source_type":"Project","user_id":6,"notification_level":3,"created_at":"2016-06-14T15:02:03.811Z","updated_at":"2016-06-14T15:02:03.811Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"user":{"id":6,"email":"saul_will@gitlabexample.com","username":"saul_will"}}
+{"id":34,"access_level":20,"source_id":5,"source_type":"Project","user_id":15,"notification_level":3,"created_at":"2016-06-14T15:02:03.776Z","updated_at":"2016-06-14T15:02:03.776Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"user":{"id":15,"email":"breanna_sanford@wolf.com","username":"emmet.schamberger"}}
+{"id":33,"access_level":20,"source_id":5,"source_type":"Project","user_id":26,"notification_level":3,"created_at":"2016-06-14T15:02:03.742Z","updated_at":"2016-06-14T15:02:03.742Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"user":{"id":26,"email":"user4@example.com","username":"user4"}}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_branches.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_branches.ndjson
new file mode 100644
index 00000000000..abd2b40cf7b
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_branches.ndjson
@@ -0,0 +1 @@
+{"id":1,"project_id":9,"name":"master","created_at":"2016-08-30T07:32:52.426Z","updated_at":"2016-08-30T07:32:52.426Z","merge_access_levels":[{"id":1,"protected_branch_id":1,"access_level":40,"created_at":"2016-08-30T07:32:52.458Z","updated_at":"2016-08-30T07:32:52.458Z"}],"push_access_levels":[{"id":1,"protected_branch_id":1,"access_level":40,"created_at":"2016-08-30T07:32:52.490Z","updated_at":"2016-08-30T07:32:52.490Z"}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_environments.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_environments.ndjson
new file mode 100644
index 00000000000..55afaa8bcf6
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_environments.ndjson
@@ -0,0 +1 @@
+{ "id": 1, "project_id": 9, "created_at": "2017-10-19T15:36:23.466Z", "updated_at": "2017-10-19T15:36:23.466Z", "name": "production", "deploy_access_levels": [ { "id": 1, "protected_environment_id": 1, "created_at": "2017-10-19T15:36:23.466Z", "updated_at": "2017-10-19T15:36:23.466Z", "access_level": 40, "user_id": 1, "group_id": null } ] }
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_tags.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_tags.ndjson
new file mode 100644
index 00000000000..441c7c3737f
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/protected_tags.ndjson
@@ -0,0 +1 @@
+{"id":1,"project_id":9,"name":"v*","created_at":"2017-04-04T13:48:13.426Z","updated_at":"2017-04-04T13:48:13.426Z","create_access_levels":[{"id":1,"protected_tag_id":1,"access_level":40,"created_at":"2017-04-04T13:48:13.458Z","updated_at":"2017-04-04T13:48:13.458Z"}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/releases.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/releases.ndjson
new file mode 100644
index 00000000000..0c14c023378
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/releases.ndjson
@@ -0,0 +1 @@
+{"id":1,"tag":"release-1.1","description":"Some release notes","project_id":5,"created_at":"2019-12-26T10:17:14.621Z","updated_at":"2019-12-26T10:17:14.621Z","author_id":1,"name":"release-1.1","sha":"901de3a8bd5573f4a049b1457d28bc1592ba6bf9","released_at":"2019-12-26T10:17:14.615Z","links":[{"id":1,"release_id":1,"url":"http://localhost/namespace6/project6/-/jobs/140463678/artifacts/download","name":"release-1.1.dmg","created_at":"2019-12-26T10:17:14.621Z","updated_at":"2019-12-26T10:17:14.621Z"}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/services.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/services.ndjson
new file mode 100644
index 00000000000..6d6afd3af0b
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/services.ndjson
@@ -0,0 +1,19 @@
+{"id":101,"title":"YouTrack","project_id":5,"created_at":"2016-06-14T15:01:51.327Z","updated_at":"2016-06-14T15:01:51.327Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"YoutrackService","category":"issue_tracker","default":false,"wiki_page_events":true}
+{"id":100,"title":"JetBrains TeamCity CI","project_id":5,"created_at":"2016-06-14T15:01:51.315Z","updated_at":"2016-06-14T15:01:51.315Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"TeamcityService","category":"ci","default":false,"wiki_page_events":true}
+{"id":99,"title":"Slack","project_id":5,"created_at":"2016-06-14T15:01:51.303Z","updated_at":"2016-06-14T15:01:51.303Z","active":false,"properties":{"notify_only_broken_pipelines":true},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"pipeline_events":true,"type":"SlackService","category":"common","default":false,"wiki_page_events":true}
+{"id":98,"title":"Redmine","project_id":5,"created_at":"2016-06-14T15:01:51.289Z","updated_at":"2016-06-14T15:01:51.289Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"RedmineService","category":"issue_tracker","default":false,"wiki_page_events":true}
+{"id":97,"title":"Pushover","project_id":5,"created_at":"2016-06-14T15:01:51.277Z","updated_at":"2016-06-14T15:01:51.277Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"PushoverService","category":"common","default":false,"wiki_page_events":true}
+{"id":96,"title":"PivotalTracker","project_id":5,"created_at":"2016-06-14T15:01:51.267Z","updated_at":"2016-06-14T15:01:51.267Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"PivotalTrackerService","category":"common","default":false,"wiki_page_events":true}
+{"id":95,"title":"Jira","project_id":5,"created_at":"2016-06-14T15:01:51.255Z","updated_at":"2016-06-14T15:01:51.255Z","active":false,"properties":{"api_url":"","jira_issue_transition_id":"2"},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"JiraService","category":"issue_tracker","default":false,"wiki_page_events":true}
+{"id":94,"title":"Irker (IRC gateway)","project_id":5,"created_at":"2016-06-14T15:01:51.232Z","updated_at":"2016-06-14T15:01:51.232Z","active":true,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"IrkerService","category":"common","default":false,"wiki_page_events":true}
+{"id":93,"title":"HipChat","project_id":5,"created_at":"2016-06-14T15:01:51.219Z","updated_at":"2016-06-14T15:01:51.219Z","active":false,"properties":{"notify_only_broken_pipelines":true},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"pipeline_events":true,"type":"HipchatService","category":"common","default":false,"wiki_page_events":true}
+{"id":91,"title":"Flowdock","project_id":5,"created_at":"2016-06-14T15:01:51.182Z","updated_at":"2016-06-14T15:01:51.182Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"FlowdockService","category":"common","default":false,"wiki_page_events":true}
+{"id":90,"title":"External Wiki","project_id":5,"created_at":"2016-06-14T15:01:51.166Z","updated_at":"2016-06-14T15:01:51.166Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"ExternalWikiService","category":"common","default":false,"wiki_page_events":true}
+{"id":89,"title":"Emails on push","project_id":5,"created_at":"2016-06-14T15:01:51.153Z","updated_at":"2016-06-14T15:01:51.153Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"EmailsOnPushService","category":"common","default":false,"wiki_page_events":true}
+{"id":88,"title":"Drone CI","project_id":5,"created_at":"2016-06-14T15:01:51.139Z","updated_at":"2016-06-14T15:01:51.139Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"DroneCiService","category":"ci","default":false,"wiki_page_events":true}
+{"id":87,"title":"Custom Issue Tracker","project_id":5,"created_at":"2016-06-14T15:01:51.125Z","updated_at":"2016-06-14T15:01:51.125Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"CustomIssueTrackerService","category":"issue_tracker","default":false,"wiki_page_events":true}
+{"id":86,"title":"Campfire","project_id":5,"created_at":"2016-06-14T15:01:51.113Z","updated_at":"2016-06-14T15:01:51.113Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"CampfireService","category":"common","default":false,"wiki_page_events":true}
+{"id":84,"title":"Buildkite","project_id":5,"created_at":"2016-06-14T15:01:51.080Z","updated_at":"2016-06-14T15:01:51.080Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"BuildkiteService","category":"ci","default":false,"wiki_page_events":true}
+{"id":83,"title":"Atlassian Bamboo CI","project_id":5,"created_at":"2016-06-14T15:01:51.067Z","updated_at":"2016-06-14T15:01:51.067Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"BambooService","category":"ci","default":false,"wiki_page_events":true}
+{"id":82,"title":"Assembla","project_id":5,"created_at":"2016-06-14T15:01:51.047Z","updated_at":"2016-06-14T15:01:51.047Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"AssemblaService","category":"common","default":false,"wiki_page_events":true}
+{"id":81,"title":"Asana","project_id":5,"created_at":"2016-06-14T15:01:51.031Z","updated_at":"2016-06-14T15:01:51.031Z","active":false,"properties":{},"template":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"AsanaService","category":"common","default":false,"wiki_page_events":true}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/snippets.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/snippets.ndjson
new file mode 100644
index 00000000000..7d626090aa4
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/snippets.ndjson
@@ -0,0 +1 @@
+{"id":1,"title":"Test snippet title","content":"x = 1","author_id":1,"project_id":1,"created_at":"2019-11-05T15:06:06.579Z","updated_at":"2019-11-05T15:06:06.579Z","file_name":"","visibility_level":20,"description":"Test snippet description","award_emoji":[{"id":1,"name":"thumbsup","user_id":1,"awardable_type":"Snippet","awardable_id":1,"created_at":"2019-11-05T15:37:21.287Z","updated_at":"2019-11-05T15:37:21.287Z"},{"id":2,"name":"coffee","user_id":1,"awardable_type":"Snippet","awardable_id":1,"created_at":"2019-11-05T15:37:24.645Z","updated_at":"2019-11-05T15:37:24.645Z"}],"notes":[{"id":872,"note":"This is a test note","noteable_type":"Snippet","author_id":1,"created_at":"2019-11-05T15:37:24.645Z","updated_at":"2019-11-05T15:37:24.645Z","noteable_id":1,"author":{"name":"Random name"},"events":[],"award_emoji":[{"id":12,"name":"thumbsup","user_id":1,"awardable_type":"Note","awardable_id":872,"created_at":"2019-11-05T15:37:21.287Z","updated_at":"2019-11-05T15:37:21.287Z"}]}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/triggers.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/triggers.ndjson
new file mode 100644
index 00000000000..93619f4fb44
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/triggers.ndjson
@@ -0,0 +1,2 @@
+{"id":123,"token":"cdbfasdf44a5958c83654733449e585","project_id":5,"owner_id":1,"created_at":"2017-01-16T15:25:28.637Z","updated_at":"2017-01-16T15:25:28.637Z"}
+{"id":456,"token":"33a66349b5ad01fc00174af87804e40","project_id":5,"created_at":"2017-01-16T15:25:29.637Z","updated_at":"2017-01-16T15:25:29.637Z"}
diff --git a/spec/fixtures/lib/gitlab/import_export/designs/project.json b/spec/fixtures/lib/gitlab/import_export/designs/project.json
new file mode 100644
index 00000000000..28eaa38d387
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/designs/project.json
@@ -0,0 +1,502 @@
+{
+ "description":"",
+ "visibility_level":0,
+ "archived":false,
+ "merge_requests_template":null,
+ "merge_requests_rebase_enabled":false,
+ "approvals_before_merge":0,
+ "reset_approvals_on_push":true,
+ "merge_requests_ff_only_enabled":false,
+ "issues_template":null,
+ "shared_runners_enabled":true,
+ "build_coverage_regex":null,
+ "build_allow_git_fetch":true,
+ "build_timeout":3600,
+ "pending_delete":false,
+ "public_builds":true,
+ "last_repository_check_failed":null,
+ "container_registry_enabled":true,
+ "only_allow_merge_if_pipeline_succeeds":false,
+ "has_external_issue_tracker":false,
+ "request_access_enabled":false,
+ "has_external_wiki":false,
+ "ci_config_path":null,
+ "only_allow_merge_if_all_discussions_are_resolved":false,
+ "repository_size_limit":null,
+ "printing_merge_request_link_enabled":true,
+ "auto_cancel_pending_pipelines":"enabled",
+ "service_desk_enabled":null,
+ "delete_error":null,
+ "disable_overriding_approvers_per_merge_request":null,
+ "resolve_outdated_diff_discussions":false,
+ "jobs_cache_index":null,
+ "external_authorization_classification_label":null,
+ "pages_https_only":false,
+ "external_webhook_token":null,
+ "merge_requests_author_approval":null,
+ "merge_requests_disable_committers_approval":null,
+ "require_password_to_approve":null,
+ "labels":[
+
+ ],
+ "milestones":[
+
+ ],
+ "issues":[
+ {
+ "id":469,
+ "title":"issue 1",
+ "author_id":1,
+ "project_id":30,
+ "created_at":"2019-08-07T03:57:55.007Z",
+ "updated_at":"2019-08-07T03:57:55.007Z",
+ "description":"",
+ "state":"opened",
+ "iid":1,
+ "updated_by_id":null,
+ "weight":null,
+ "confidential":false,
+ "due_date":null,
+ "moved_to_id":null,
+ "lock_version":0,
+ "time_estimate":0,
+ "relative_position":1073742323,
+ "service_desk_reply_to":null,
+ "last_edited_at":null,
+ "last_edited_by_id":null,
+ "discussion_locked":null,
+ "closed_at":null,
+ "closed_by_id":null,
+ "state_id":1,
+ "events":[
+ {
+ "id":1775,
+ "project_id":30,
+ "author_id":1,
+ "target_id":469,
+ "created_at":"2019-08-07T03:57:55.158Z",
+ "updated_at":"2019-08-07T03:57:55.158Z",
+ "target_type":"Issue",
+ "action":1
+ }
+ ],
+ "timelogs":[
+
+ ],
+ "notes":[
+
+ ],
+ "label_links":[
+
+ ],
+ "resource_label_events":[
+
+ ],
+ "issue_assignees":[
+
+ ],
+ "designs":[
+ {
+ "id":38,
+ "project_id":30,
+ "issue_id":469,
+ "filename":"chirrido3.jpg",
+ "notes":[
+
+ ]
+ },
+ {
+ "id":39,
+ "project_id":30,
+ "issue_id":469,
+ "filename":"jonathan_richman.jpg",
+ "notes":[
+
+ ]
+ },
+ {
+ "id":40,
+ "project_id":30,
+ "issue_id":469,
+ "filename":"mariavontrap.jpeg",
+ "notes":[
+
+ ]
+ }
+ ],
+ "design_versions":[
+ {
+ "id":24,
+ "sha":"9358d1bac8ff300d3d2597adaa2572a20f7f8703",
+ "issue_id":469,
+ "author_id":1,
+ "actions":[
+ {
+ "design_id":38,
+ "version_id":24,
+ "event":0,
+ "design":{
+ "id":38,
+ "project_id":30,
+ "issue_id":469,
+ "filename":"chirrido3.jpg"
+ }
+ }
+ ]
+ },
+ {
+ "id":25,
+ "sha":"e1a4a501bcb42f291f84e5d04c8f927821542fb6",
+ "issue_id":469,
+ "author_id":2,
+ "actions":[
+ {
+ "design_id":38,
+ "version_id":25,
+ "event":1,
+ "design":{
+ "id":38,
+ "project_id":30,
+ "issue_id":469,
+ "filename":"chirrido3.jpg"
+ }
+ },
+ {
+ "design_id":39,
+ "version_id":25,
+ "event":0,
+ "design":{
+ "id":39,
+ "project_id":30,
+ "issue_id":469,
+ "filename":"jonathan_richman.jpg"
+ }
+ }
+ ]
+ },
+ {
+ "id":26,
+ "sha":"27702d08f5ee021ae938737f84e8fe7c38599e85",
+ "issue_id":469,
+ "author_id":1,
+ "actions":[
+ {
+ "design_id":38,
+ "version_id":26,
+ "event":1,
+ "design":{
+ "id":38,
+ "project_id":30,
+ "issue_id":469,
+ "filename":"chirrido3.jpg"
+ }
+ },
+ {
+ "design_id":39,
+ "version_id":26,
+ "event":2,
+ "design":{
+ "id":39,
+ "project_id":30,
+ "issue_id":469,
+ "filename":"jonathan_richman.jpg"
+ }
+ },
+ {
+ "design_id":40,
+ "version_id":26,
+ "event":0,
+ "design":{
+ "id":40,
+ "project_id":30,
+ "issue_id":469,
+ "filename":"mariavontrap.jpeg"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id":470,
+ "title":"issue 2",
+ "author_id":1,
+ "project_id":30,
+ "created_at":"2019-08-07T04:15:57.607Z",
+ "updated_at":"2019-08-07T04:15:57.607Z",
+ "description":"",
+ "state":"opened",
+ "iid":2,
+ "updated_by_id":null,
+ "weight":null,
+ "confidential":false,
+ "due_date":null,
+ "moved_to_id":null,
+ "lock_version":0,
+ "time_estimate":0,
+ "relative_position":1073742823,
+ "service_desk_reply_to":null,
+ "last_edited_at":null,
+ "last_edited_by_id":null,
+ "discussion_locked":null,
+ "closed_at":null,
+ "closed_by_id":null,
+ "state_id":1,
+ "events":[
+ {
+ "id":1776,
+ "project_id":30,
+ "author_id":1,
+ "target_id":470,
+ "created_at":"2019-08-07T04:15:57.789Z",
+ "updated_at":"2019-08-07T04:15:57.789Z",
+ "target_type":"Issue",
+ "action":1
+ }
+ ],
+ "timelogs":[
+
+ ],
+ "notes":[
+
+ ],
+ "label_links":[
+
+ ],
+ "resource_label_events":[
+
+ ],
+ "issue_assignees":[
+
+ ],
+ "designs":[
+ {
+ "id":42,
+ "project_id":30,
+ "issue_id":470,
+ "filename":"1 (1).jpeg",
+ "notes":[
+
+ ]
+ },
+ {
+ "id":43,
+ "project_id":30,
+ "issue_id":470,
+ "filename":"2099743.jpg",
+ "notes":[
+
+ ]
+ },
+ {
+ "id":44,
+ "project_id":30,
+ "issue_id":470,
+ "filename":"a screenshot (1).jpg",
+ "notes":[
+
+ ]
+ },
+ {
+ "id":41,
+ "project_id":30,
+ "issue_id":470,
+ "filename":"chirrido3.jpg",
+ "notes":[
+
+ ]
+ }
+ ],
+ "design_versions":[
+ {
+ "id":27,
+ "sha":"8587e78ab6bda3bc820a9f014c3be4a21ad4fcc8",
+ "issue_id":470,
+ "author_id":1,
+ "actions":[
+ {
+ "design_id":41,
+ "version_id":27,
+ "event":0,
+ "design":{
+ "id":41,
+ "project_id":30,
+ "issue_id":470,
+ "filename":"chirrido3.jpg"
+ }
+ }
+ ]
+ },
+ {
+ "id":28,
+ "sha":"73f871b4c8c1d65c62c460635e023179fb53abc4",
+ "issue_id":470,
+ "author_id":2,
+ "actions":[
+ {
+ "design_id":42,
+ "version_id":28,
+ "event":0,
+ "design":{
+ "id":42,
+ "project_id":30,
+ "issue_id":470,
+ "filename":"1 (1).jpeg"
+ }
+ },
+ {
+ "design_id":43,
+ "version_id":28,
+ "event":0,
+ "design":{
+ "id":43,
+ "project_id":30,
+ "issue_id":470,
+ "filename":"2099743.jpg"
+ }
+ }
+ ]
+ },
+ {
+ "id":29,
+ "sha":"c9b5f067f3e892122a4b12b0a25a8089192f3ac8",
+ "issue_id":470,
+ "author_id":2,
+ "actions":[
+ {
+ "design_id":42,
+ "version_id":29,
+ "event":1,
+ "design":{
+ "id":42,
+ "project_id":30,
+ "issue_id":470,
+ "filename":"1 (1).jpeg"
+ }
+ },
+ {
+ "design_id":44,
+ "version_id":29,
+ "event":0,
+ "design":{
+ "id":44,
+ "project_id":30,
+ "issue_id":470,
+ "filename":"a screenshot (1).jpg"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "snippets":[
+
+ ],
+ "releases":[
+
+ ],
+ "project_members":[
+ {
+ "id":95,
+ "access_level":40,
+ "source_id":30,
+ "source_type":"Project",
+ "user_id":1,
+ "notification_level":3,
+ "created_at":"2019-08-07T03:57:32.825Z",
+ "updated_at":"2019-08-07T03:57:32.825Z",
+ "created_by_id":1,
+ "invite_email":null,
+ "invite_token":null,
+ "invite_accepted_at":null,
+ "requested_at":null,
+ "expires_at":null,
+ "ldap":false,
+ "override":false,
+ "user":{
+ "id":1,
+ "email":"admin@example.com",
+ "username":"root"
+ }
+ },
+ {
+ "id":96,
+ "access_level":40,
+ "source_id":30,
+ "source_type":"Project",
+ "user_id":2,
+ "notification_level":3,
+ "created_at":"2019-08-07T03:57:32.825Z",
+ "updated_at":"2019-08-07T03:57:32.825Z",
+ "created_by_id":null,
+ "invite_email":null,
+ "invite_token":null,
+ "invite_accepted_at":null,
+ "requested_at":null,
+ "expires_at":null,
+ "ldap":false,
+ "override":false,
+ "user":{
+ "id":2,
+ "email":"user_2@gitlabexample.com",
+ "username":"user_2"
+ }
+ }
+ ],
+ "merge_requests":[
+
+ ],
+ "ci_pipelines":[
+
+ ],
+ "triggers":[
+
+ ],
+ "pipeline_schedules":[
+
+ ],
+ "services":[
+
+ ],
+ "protected_branches":[
+
+ ],
+ "protected_environments": [
+
+ ],
+ "protected_tags":[
+
+ ],
+ "project_feature":{
+ "id":30,
+ "project_id":30,
+ "merge_requests_access_level":20,
+ "issues_access_level":20,
+ "wiki_access_level":20,
+ "snippets_access_level":20,
+ "builds_access_level":20,
+ "created_at":"2019-08-07T03:57:32.485Z",
+ "updated_at":"2019-08-07T03:57:32.485Z",
+ "repository_access_level":20,
+ "pages_access_level":10
+ },
+ "custom_attributes":[
+
+ ],
+ "prometheus_metrics":[
+
+ ],
+ "project_badges":[
+
+ ],
+ "ci_cd_settings":{
+ "group_runners_enabled":true
+ },
+ "boards":[
+
+ ],
+ "pipelines":[
+
+ ]
+}
diff --git a/spec/fixtures/lib/gitlab/import_export/group/tree.tar.gz b/spec/fixtures/lib/gitlab/import_export/group/tree.tar.gz
deleted file mode 100644
index 0788ca18fb3..00000000000
--- a/spec/fixtures/lib/gitlab/import_export/group/tree.tar.gz
+++ /dev/null
Binary files differ
diff --git a/spec/fixtures/lib/gitlab/import_export/group/tree/project.json b/spec/fixtures/lib/gitlab/import_export/group/tree/project.json
new file mode 100644
index 00000000000..df38e1746e5
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group/tree/project.json
@@ -0,0 +1 @@
+{"id":5,"description":"Nisi et repellendus ut enim quo accusamus vel magnam.","visibility_level":10,"archived":false,"hooks":[]}
diff --git a/spec/fixtures/lib/gitlab/import_export/group/tree/project/issues.ndjson b/spec/fixtures/lib/gitlab/import_export/group/tree/project/issues.ndjson
new file mode 100644
index 00000000000..4759e97228f
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group/tree/project/issues.ndjson
@@ -0,0 +1,3 @@
+{"id":1,"title":"Fugiat est minima quae maxime non similique.","assignee_id":null,"project_id":8,"author_id":1,"created_at":"2017-07-07T18:13:01.138Z","updated_at":"2017-08-15T18:37:40.807Z","branch_name":null,"description":"Quam totam fuga numquam in eveniet.","state":"opened","iid":1,"updated_by_id":1,"confidential":false,"due_date":null,"moved_to_id":null,"lock_version":null,"time_estimate":0,"closed_at":null,"last_edited_at":null,"last_edited_by_id":null,"group_milestone_id":null,"milestone":{"id":1,"title":"Project milestone","project_id":8,"description":"Project-level milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"group_id":null},"label_links":[{"id":11,"label_id":6,"target_id":1,"target_type":"Issue","created_at":"2017-08-15T18:37:40.795Z","updated_at":"2017-08-15T18:37:40.795Z","label":{"id":6,"title":"group label","color":"#A8D695","project_id":null,"created_at":"2017-08-15T18:37:19.698Z","updated_at":"2017-08-15T18:37:19.698Z","template":false,"description":"","group_id":5,"type":"GroupLabel","priorities":[]}},{"id":11,"label_id":2,"target_id":1,"target_type":"Issue","created_at":"2017-08-15T18:37:40.795Z","updated_at":"2017-08-15T18:37:40.795Z","label":{"id":6,"title":"A project label","color":"#A8D695","project_id":null,"created_at":"2017-08-15T18:37:19.698Z","updated_at":"2017-08-15T18:37:19.698Z","template":false,"description":"","group_id":5,"type":"ProjectLabel","priorities":[]}}]}
+{"id":2,"title":"Fugiat est minima quae maxime non similique.","assignee_id":null,"project_id":8,"author_id":1,"created_at":"2017-07-07T18:13:01.138Z","updated_at":"2017-08-15T18:37:40.807Z","branch_name":null,"description":"Quam totam fuga numquam in eveniet.","state":"closed","iid":2,"updated_by_id":1,"confidential":false,"due_date":null,"moved_to_id":null,"lock_version":null,"time_estimate":0,"closed_at":null,"last_edited_at":null,"last_edited_by_id":null,"group_milestone_id":null,"milestone":{"id":2,"title":"A group milestone","description":"Group-level milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"group_id":100},"label_links":[{"id":11,"label_id":2,"target_id":1,"target_type":"Issue","created_at":"2017-08-15T18:37:40.795Z","updated_at":"2017-08-15T18:37:40.795Z","label":{"id":2,"title":"A project label","color":"#A8D695","project_id":null,"created_at":"2017-08-15T18:37:19.698Z","updated_at":"2017-08-15T18:37:19.698Z","template":false,"description":"","group_id":5,"type":"ProjectLabel","priorities":[]}}]}
+{"id":3,"title":"Issue with Epic","author_id":1,"project_id":8,"created_at":"2019-12-08T19:41:11.233Z","updated_at":"2019-12-08T19:41:53.194Z","position":0,"branch_name":null,"description":"Donec at nulla vitae sem molestie rutrum ut at sem.","state":"opened","iid":3,"updated_by_id":null,"confidential":false,"due_date":null,"moved_to_id":null,"issue_assignees":[],"notes":[],"milestone":{"id":2,"title":"A group milestone","description":"Group-level milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"group_id":100},"epic_issue":{"id":78,"relative_position":1073740323,"epic":{"id":1,"group_id":5,"author_id":1,"assignee_id":null,"iid":1,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-12-08T19:37:07.098Z","updated_at":"2019-12-08T19:43:11.568Z","title":"An epic","description":null,"start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"milestone_id":null}}}
diff --git a/spec/fixtures/lib/gitlab/import_export/group/tree/project/labels.ndjson b/spec/fixtures/lib/gitlab/import_export/group/tree/project/labels.ndjson
new file mode 100644
index 00000000000..28894a8a404
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group/tree/project/labels.ndjson
@@ -0,0 +1 @@
+{"id":2,"title":"A project label","color":"#428bca","project_id":8,"created_at":"2016-07-22T08:55:44.161Z","updated_at":"2016-07-22T08:55:44.161Z","template":false,"description":"","type":"ProjectLabel","priorities":[{"id":1,"project_id":5,"label_id":1,"priority":1,"created_at":"2016-10-18T09:35:43.338Z","updated_at":"2016-10-18T09:35:43.338Z"}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/group/tree/project/milestones.ndjson b/spec/fixtures/lib/gitlab/import_export/group/tree/project/milestones.ndjson
new file mode 100644
index 00000000000..29166527a9d
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group/tree/project/milestones.ndjson
@@ -0,0 +1 @@
+{"id":1,"title":"Project milestone","project_id":8,"description":"Project-level milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"group_id":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/child_with_no_parent/tree/groups/4351.json b/spec/fixtures/lib/gitlab/import_export/group_exports/child_with_no_parent/tree/groups/4351.json
new file mode 100644
index 00000000000..ce657ded7b0
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/child_with_no_parent/tree/groups/4351.json
@@ -0,0 +1 @@
+{"name":"ymg09t5704clnxnqfgaj2h098gz4r7gyx4wc3fzmlqj1en24zf","path":"ymg09t5704clnxnqfgaj2h098gz4r7gyx4wc3fzmlqj1en24zf","owner_id":123,"created_at":"2019-11-20 17:01:53 UTC","updated_at":"2019-11-20 17:05:44 UTC","description":"Group Description","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":0,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":null,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"saml_discovery_token":"rBKx3ioz","custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"runners_token":"token","runners_token_encrypted":"encrypted","subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"id":4351} \ No newline at end of file
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/child_with_no_parent/tree/groups/4352.json b/spec/fixtures/lib/gitlab/import_export/group_exports/child_with_no_parent/tree/groups/4352.json
new file mode 100644
index 00000000000..b67f863fcea
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/child_with_no_parent/tree/groups/4352.json
@@ -0,0 +1 @@
+{"name":"pwip17beq7vl4nuwz9ie7bk8navpxj1w04zylmmjveab5bargr","path":"pwip17beq7vl4nuwz9ie7bk8navpxj1w04zylmmjveab5bargr","owner_id":null,"created_at":"2019-11-20 17:01:53 UTC","updated_at":"2019-11-20 17:05:44 UTC","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":0,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":null,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"saml_discovery_token":"ki3Xnjw3","custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"id":4352}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/child_with_no_parent/tree/groups/_all.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/child_with_no_parent/tree/groups/_all.ndjson
new file mode 100644
index 00000000000..078909d30fb
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/child_with_no_parent/tree/groups/_all.ndjson
@@ -0,0 +1,2 @@
+4351
+4352
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351.json b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351.json
new file mode 100644
index 00000000000..ce657ded7b0
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351.json
@@ -0,0 +1 @@
+{"name":"ymg09t5704clnxnqfgaj2h098gz4r7gyx4wc3fzmlqj1en24zf","path":"ymg09t5704clnxnqfgaj2h098gz4r7gyx4wc3fzmlqj1en24zf","owner_id":123,"created_at":"2019-11-20 17:01:53 UTC","updated_at":"2019-11-20 17:05:44 UTC","description":"Group Description","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":0,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":null,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"saml_discovery_token":"rBKx3ioz","custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"runners_token":"token","runners_token_encrypted":"encrypted","subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"id":4351} \ No newline at end of file
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/badges.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/badges.ndjson
new file mode 100644
index 00000000000..3c04897594e
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/badges.ndjson
@@ -0,0 +1 @@
+{"id":10,"link_url":"https://localhost:3443/%{default_branch}","image_url":"https://badge_image.png","project_id":null,"group_id":4351,"created_at":"2019-11-20T17:27:02.047Z","updated_at":"2019-11-20T17:27:02.047Z","type":"GroupBadge"}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/boards.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/boards.ndjson
new file mode 100644
index 00000000000..a3e28584ff5
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/boards.ndjson
@@ -0,0 +1 @@
+{"id":173,"project_id":null,"created_at":"2020-02-11T14:35:51.561Z","updated_at":"2020-02-11T14:35:51.561Z","name":"first board","milestone_id":null,"group_id":4351,"weight":null,"lists":[{"id":189,"board_id":173,"label_id":271,"list_type":"label","position":0,"created_at":"2020-02-11T14:35:57.131Z","updated_at":"2020-02-11T14:35:57.131Z","user_id":null,"milestone_id":null,"max_issue_count":0,"max_issue_weight":0,"label":{"id":271,"title":"TSL","color":"#58796f","project_id":null,"created_at":"2019-11-20T17:02:20.541Z","updated_at":"2020-02-06T15:44:52.048Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[]},"board":{"id":173,"project_id":null,"created_at":"2020-02-11T14:35:51.561Z","updated_at":"2020-02-11T14:35:51.561Z","name":"hi","milestone_id":null,"group_id":4351,"weight":null}},{"id":190,"board_id":173,"label_id":272,"list_type":"label","position":1,"created_at":"2020-02-11T14:35:57.868Z","updated_at":"2020-02-11T14:35:57.868Z","user_id":null,"milestone_id":null,"max_issue_count":0,"max_issue_weight":0,"label":{"id":272,"title":"Sosync","color":"#110320","project_id":null,"created_at":"2019-11-20T17:02:20.532Z","updated_at":"2020-02-06T15:44:52.057Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[]},"board":{"id":173,"project_id":null,"created_at":"2020-02-11T14:35:51.561Z","updated_at":"2020-02-11T14:35:51.561Z","name":"hi","milestone_id":null,"group_id":4351,"weight":null}},{"id":188,"board_id":173,"label_id":null,"list_type":"closed","position":null,"created_at":"2020-02-11T14:35:51.593Z","updated_at":"2020-02-11T14:35:51.593Z","user_id":null,"milestone_id":null,"max_issue_count":0,"max_issue_weight":0}],"labels":[]}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/epics.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/epics.ndjson
new file mode 100644
index 00000000000..e461cbb537e
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/epics.ndjson
@@ -0,0 +1,5 @@
+{"id":13622,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":1,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.754Z","updated_at":"2019-11-20T18:38:40.054Z","title":"Provident neque consequatur numquam ad laboriosam voluptatem magnam.","description":"Fugit nisi est ut numquam quia rerum vitae qui. Et in est aliquid voluptas et ut vitae. In distinctio voluptates ut deleniti iste.\n\nReiciendis eum sunt vero blanditiis at quia. Voluptate eum facilis illum ea distinctio maiores. Doloribus aut nemo ea distinctio.\n\nNihil cum distinctio voluptates quam. Laboriosam distinctio ea accusantium soluta perspiciatis nesciunt impedit. Id qui natus quis minima voluptatum velit ut reprehenderit. Molestiae quia est harum sapiente rem error architecto id. Et minus ipsa et ut ut.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"notes":[{"id":44170,"note":"added epic &5 as child epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:40.031Z","updated_at":"2019-11-20T18:38:40.035Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13622,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"ba005d8dd59cd37a4f32406d46e759b08fd15510","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"},"award_emoji":[{"id":12,"name":"drum","user_id":1,"awardable_type":"Note","awardable_id":44170,"created_at":"2019-11-05T15:32:21.287Z","updated_at":"2019-11-05T15:32:21.287Z"}]},{"id":44168,"note":"added epic &4 as child epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:35.669Z","updated_at":"2019-11-20T18:38:35.673Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13622,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"9b49d3b017aadc1876d477b960e6f8efb99ce29f","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}},{"id":44166,"note":"added epic &3 as child epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:30.944Z","updated_at":"2019-11-20T18:38:30.948Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13622,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"cccfe967f48e699a466c87a55a9f8acb00fec1a1","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}},{"id":44164,"note":"added epic &2 as child epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:26.689Z","updated_at":"2019-11-20T18:38:26.724Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13622,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"133f0c3001860fa8d2031e398a65db74477378c4","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}}],"award_emoji":[{"id":12,"name":"thumbsup","user_id":1,"awardable_type":"Epic","awardable_id":13622,"created_at":"2019-11-05T15:37:21.287Z","updated_at":"2019-11-05T15:37:21.287Z"}]}
+{"id":13623,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":2,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.769Z","updated_at":"2019-11-20T18:38:26.851Z","title":"Omnis accusantium commodi voluptas odio illo eum ut.","description":"Eius vero et iste amet est voluptatem modi. Doloribus ipsam beatae et ut autem ut animi. Dolor culpa dolor omnis delectus est tempora inventore ab. Optio labore tenetur libero quia provident et quis. Blanditiis architecto sint possimus cum aut adipisci.\n\nDolores quisquam sunt cupiditate unde qui vitae nemo. Odio quas omnis ut nobis. Possimus fugit deserunt quia sed ab numquam veritatis nihil.\n\nQui nemo adipisci magnam perferendis voluptatem modi. Eius enim iure dolores consequuntur eum nobis adipisci. Consequatur architecto et quas deleniti hic id laborum officiis. Enim perferendis quis quasi totam delectus rerum deleniti.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":13622,"relative_position":1073741323,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"parent":{"id":13622,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":1,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.754Z","updated_at":"2019-11-20T18:38:40.054Z","title":"Provident neque consequatur numquam ad laboriosam voluptatem magnam.","description":"Fugit nisi est ut numquam quia rerum vitae qui. Et in est aliquid voluptas et ut vitae. In distinctio voluptates ut deleniti iste.\n\nReiciendis eum sunt vero blanditiis at quia. Voluptate eum facilis illum ea distinctio maiores. Doloribus aut nemo ea distinctio.\n\nNihil cum distinctio voluptates quam. Laboriosam distinctio ea accusantium soluta perspiciatis nesciunt impedit. Id qui natus quis minima voluptatum velit ut reprehenderit. Molestiae quia est harum sapiente rem error architecto id. Et minus ipsa et ut ut.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null},"notes":[{"id":44165,"note":"added epic &1 as parent epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:26.822Z","updated_at":"2019-11-20T18:38:26.826Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13623,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"15f0a7f4ed16a07bc78841e122524bb867edcf86","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}}]}
+{"id":13624,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":3,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.783Z","updated_at":"2019-11-20T18:38:31.018Z","title":"Quis dolore velit possimus eaque aut amet esse voluptate aliquam.","description":"Ab veritatis reprehenderit nulla laboriosam et sed asperiores corporis. Est accusantium maxime perferendis et. Omnis a qui voluptates non excepturi.\n\nAdipisci labore maiores dicta sed magnam aut. Veritatis delectus dolorum qui id. Dolorum tenetur quo iure amet. Eveniet reprehenderit dolor ipsam quia ratione quo. Facilis voluptatem vel repellat id illum.\n\nAut et magnam aut minus aspernatur. Fuga quo necessitatibus mollitia maxime quasi. Qui aspernatur quia accusamus est quod. Qui assumenda veritatis dolor non eveniet quibusdam quos qui.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":13622,"relative_position":1073740823,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"parent":{"id":13622,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":1,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.754Z","updated_at":"2019-11-20T18:38:40.054Z","title":"Provident neque consequatur numquam ad laboriosam voluptatem magnam.","description":"Fugit nisi est ut numquam quia rerum vitae qui. Et in est aliquid voluptas et ut vitae. In distinctio voluptates ut deleniti iste.\n\nReiciendis eum sunt vero blanditiis at quia. Voluptate eum facilis illum ea distinctio maiores. Doloribus aut nemo ea distinctio.\n\nNihil cum distinctio voluptates quam. Laboriosam distinctio ea accusantium soluta perspiciatis nesciunt impedit. Id qui natus quis minima voluptatum velit ut reprehenderit. Molestiae quia est harum sapiente rem error architecto id. Et minus ipsa et ut ut.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null},"notes":[{"id":44167,"note":"added epic &1 as parent epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:30.989Z","updated_at":"2019-11-20T18:38:30.993Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13624,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"423ffec14a3ce148c11a802eb1f2613fa8ca9a94","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}}]}
+{"id":13625,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":4,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.798Z","updated_at":"2019-11-20T18:38:35.765Z","title":"Possimus et ut iste temporibus earum cupiditate voluptatem esse assumenda amet.","description":"Et at corporis sed id rerum ullam dolore. Odio magnam corporis excepturi neque est. Est accusamus nostrum qui rerum.\n\nEt aut dolores eaque quibusdam aut quas explicabo id. Est necessitatibus praesentium omnis et vero laboriosam et. Sunt in saepe qui laudantium et voluptas.\n\nVelit sunt odit eum omnis beatae eius aut. Dolores commodi qui impedit deleniti et magnam pariatur. Aut odit amet ipsum ea atque. Itaque est ut sunt ullam eum nam.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":13622,"relative_position":1073740323,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"parent":{"id":13622,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":1,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.754Z","updated_at":"2019-11-20T18:38:40.054Z","title":"Provident neque consequatur numquam ad laboriosam voluptatem magnam.","description":"Fugit nisi est ut numquam quia rerum vitae qui. Et in est aliquid voluptas et ut vitae. In distinctio voluptates ut deleniti iste.\n\nReiciendis eum sunt vero blanditiis at quia. Voluptate eum facilis illum ea distinctio maiores. Doloribus aut nemo ea distinctio.\n\nNihil cum distinctio voluptates quam. Laboriosam distinctio ea accusantium soluta perspiciatis nesciunt impedit. Id qui natus quis minima voluptatum velit ut reprehenderit. Molestiae quia est harum sapiente rem error architecto id. Et minus ipsa et ut ut.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null},"notes":[{"id":44169,"note":"added epic &1 as parent epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:35.737Z","updated_at":"2019-11-20T18:38:35.741Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13625,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"5bc3e30d508affafc61de2b4e1d9f21039505cc3","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}}]}
+{"id":13626,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":5,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.812Z","updated_at":"2019-11-20T18:38:40.101Z","title":"Ab deleniti ipsum voluptatem dolor qui quos saepe repellat quo.","description":"Sunt minus sunt reiciendis culpa sed excepturi. Aperiam sed quod nemo nesciunt et quia molestias incidunt. Ipsum nam magnam labore eos a molestiae rerum possimus. Sequi autem asperiores voluptas assumenda.\n\nRerum ipsa quia cum ab corrupti omnis. Velit libero et nihil ipsa aut quo rem ipsam. Architecto omnis distinctio sed doloribus perspiciatis consequatur aut et. Fugit consequuntur est minima reiciendis reprehenderit et.\n\nConsequatur distinctio et ut blanditiis perferendis officiis inventore. Alias aut voluptatem in facere. Ut perferendis dolorum hic dolores. Ipsa dolorem soluta at mollitia. Placeat et ea numquam dicta molestias.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":13622,"relative_position":1073739823,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"parent":{"id":13622,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":1,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.754Z","updated_at":"2019-11-20T18:38:40.054Z","title":"Provident neque consequatur numquam ad laboriosam voluptatem magnam.","description":"Fugit nisi est ut numquam quia rerum vitae qui. Et in est aliquid voluptas et ut vitae. In distinctio voluptates ut deleniti iste.\n\nReiciendis eum sunt vero blanditiis at quia. Voluptate eum facilis illum ea distinctio maiores. Doloribus aut nemo ea distinctio.\n\nNihil cum distinctio voluptates quam. Laboriosam distinctio ea accusantium soluta perspiciatis nesciunt impedit. Id qui natus quis minima voluptatum velit ut reprehenderit. Molestiae quia est harum sapiente rem error architecto id. Et minus ipsa et ut ut.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null},"notes":[{"id":44171,"note":"added epic &1 as parent epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:40.074Z","updated_at":"2019-11-20T18:38:40.077Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13626,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"a6231acdaef5f4d2e569dfb604f1baf85c49e1a0","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/labels.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/labels.ndjson
new file mode 100644
index 00000000000..8508c4347c2
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/labels.ndjson
@@ -0,0 +1,10 @@
+{"id":23452,"title":"Bruffefunc","color":"#1d2da4","project_id":null,"created_at":"2019-11-20T17:02:20.546Z","updated_at":"2019-11-20T17:02:20.546Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23446,"title":"Cafunc","color":"#73ed5b","project_id":null,"created_at":"2019-11-20T17:02:20.526Z","updated_at":"2019-11-20T17:02:20.526Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23451,"title":"Casche","color":"#649a75","project_id":null,"created_at":"2019-11-20T17:02:20.544Z","updated_at":"2019-11-20T17:02:20.544Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23444,"title":"Cocell","color":"#1b365c","project_id":null,"created_at":"2019-11-20T17:02:20.521Z","updated_at":"2019-11-20T17:02:20.521Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23449,"title":"Packfunc","color":"#e33bba","project_id":null,"created_at":"2019-11-20T17:02:20.537Z","updated_at":"2019-11-20T17:02:20.537Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23443,"title":"Panabalt","color":"#84f708","project_id":null,"created_at":"2019-11-20T17:02:20.518Z","updated_at":"2019-11-20T17:02:20.518Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23447,"title":"Phierefunc","color":"#4ab4a8","project_id":null,"created_at":"2019-11-20T17:02:20.530Z","updated_at":"2019-11-20T17:02:20.530Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23445,"title":"Pons","color":"#47f440","project_id":null,"created_at":"2019-11-20T17:02:20.523Z","updated_at":"2019-11-20T17:02:20.523Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23448,"title":"Sosync","color":"#110320","project_id":null,"created_at":"2019-11-20T17:02:20.532Z","updated_at":"2019-11-20T17:02:20.532Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23450,"title":"TSL","color":"#58796f","project_id":null,"created_at":"2019-11-20T17:02:20.541Z","updated_at":"2019-11-20T17:02:20.541Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/members.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/members.ndjson
new file mode 100644
index 00000000000..ec3733a2b2b
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/members.ndjson
@@ -0,0 +1,6 @@
+{"id":13766,"access_level":30,"source_id":4351,"source_type":"Namespace","user_id":42,"notification_level":3,"created_at":"2019-11-20T17:04:36.184Z","updated_at":"2019-11-20T17:04:36.184Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":42,"email":"moriah@collinsmurphy.com","username":"reported_user_15"}}
+{"id":13765,"access_level":40,"source_id":4351,"source_type":"Namespace","user_id":271,"notification_level":3,"created_at":"2019-11-20T17:04:36.044Z","updated_at":"2019-11-20T17:04:36.044Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":271,"email":"garret@connellystark.ca","username":"charlesetta"}}
+{"id":13764,"access_level":30,"source_id":4351,"source_type":"Namespace","user_id":206,"notification_level":3,"created_at":"2019-11-20T17:04:35.840Z","updated_at":"2019-11-20T17:04:35.840Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":206,"email":"gwendolyn_robel@gitlabexample.com","username":"gwendolyn_robel"}}
+{"id":13763,"access_level":10,"source_id":4351,"source_type":"Namespace","user_id":39,"notification_level":3,"created_at":"2019-11-20T17:04:35.704Z","updated_at":"2019-11-20T17:04:35.704Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":39,"email":"alexis_berge@kerlukeklein.us","username":"reported_user_12"}}
+{"id":13762,"access_level":20,"source_id":4351,"source_type":"Namespace","user_id":1624,"notification_level":3,"created_at":"2019-11-20T17:04:35.566Z","updated_at":"2019-11-20T17:04:35.566Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":1624,"email":"adriene.mcclure@gitlabexample.com","username":"adriene.mcclure"}}
+{"id":12920,"access_level":50,"source_id":4351,"source_type":"Namespace","user_id":1,"notification_level":3,"created_at":"2019-11-20T17:01:53.505Z","updated_at":"2019-11-20T17:01:53.505Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":1,"email":"admin@example.com","username":"root"}}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/milestones.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/milestones.ndjson
new file mode 100644
index 00000000000..40523f276e7
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4351/milestones.ndjson
@@ -0,0 +1,5 @@
+{"id":7642,"title":"v4.0","project_id":null,"description":"Et laudantium enim omnis ea reprehenderit iure.","due_date":null,"created_at":"2019-11-20T17:02:14.336Z","updated_at":"2019-11-20T17:02:14.336Z","state":"closed","iid":5,"start_date":null,"group_id":4351}
+{"id":7641,"title":"v3.0","project_id":null,"description":"Et repellat culpa nemo consequatur ut reprehenderit.","due_date":null,"created_at":"2019-11-20T17:02:14.323Z","updated_at":"2019-11-20T17:02:14.323Z","state":"active","iid":4,"start_date":null,"group_id":4351}
+{"id":7640,"title":"v2.0","project_id":null,"description":"Velit cupiditate est neque voluptates iste rem sunt.","due_date":null,"created_at":"2019-11-20T17:02:14.309Z","updated_at":"2019-11-20T17:02:14.309Z","state":"active","iid":3,"start_date":null,"group_id":4351}
+{"id":7639,"title":"v1.0","project_id":null,"description":"Amet velit repellat ut rerum aut cum.","due_date":null,"created_at":"2019-11-20T17:02:14.296Z","updated_at":"2019-11-20T17:02:14.296Z","state":"active","iid":2,"start_date":null,"group_id":4351}
+{"id":7638,"title":"v0.0","project_id":null,"description":"Ea quia asperiores ut modi dolorem sunt non numquam.","due_date":null,"created_at":"2019-11-20T17:02:14.282Z","updated_at":"2019-11-20T17:02:14.282Z","state":"active","iid":1,"start_date":null,"group_id":4351}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352.json b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352.json
new file mode 100644
index 00000000000..45e561dcda1
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352.json
@@ -0,0 +1 @@
+{"name":"pwip17beq7vl4nuwz9ie7bk8navpxj1w04zylmmjveab5bargr","path":"pwip17beq7vl4nuwz9ie7bk8navpxj1w04zylmmjveab5bargr","owner_id":null,"created_at":"2019-11-20 17:01:53 UTC","updated_at":"2019-11-20 17:05:44 UTC","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":0,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":4351,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"saml_discovery_token":"ki3Xnjw3","custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"id":4352} \ No newline at end of file
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/badges.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/badges.ndjson
new file mode 100644
index 00000000000..5fc58de6d44
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/badges.ndjson
@@ -0,0 +1 @@
+{"id":14,"link_url":"https://localhost:3443/%{default_branch}","image_url":"https://badge_image.png","project_id":null,"group_id":4352,"created_at":"2019-11-20T17:29:36.656Z","updated_at":"2019-11-20T17:29:36.656Z","type":"GroupBadge"}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/boards.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/boards.ndjson
new file mode 100644
index 00000000000..51d6750e412
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/boards.ndjson
@@ -0,0 +1,2 @@
+{"id":64,"project_id":null,"created_at":"2019-11-20T17:29:39.872Z","updated_at":"2019-11-20T17:29:39.872Z","name":"Development","milestone_id":null,"group_id":4352,"weight":null,"labels":[]}
+{"id":65,"project_id":null,"created_at":"2019-11-20T17:29:47.304Z","updated_at":"2019-11-20T17:29:47.304Z","name":"Sub Board 4","milestone_id":null,"group_id":4352,"weight":null,"labels":[]}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/epics.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/epics.ndjson
new file mode 100644
index 00000000000..3ec50b72629
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/epics.ndjson
@@ -0,0 +1,5 @@
+{"id":13627,"milestone_id":null,"group_id":4352,"author_id":1,"assignee_id":null,"iid":1,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.848Z","updated_at":"2019-11-20T17:02:09.848Z","title":"Nobis omnis occaecati veritatis quia eveniet sed ut cupiditate ut a.","description":"Provident iusto ipsam fuga vero. Aut mollitia earum iusto doloremque recusandae enim nam et. Quas maxime sint libero dolorum aut cumque molestias quam. Iure voluptas voluptatum similique voluptatem dolorem.\n\nAnimi aliquid praesentium sint voluptatum fuga voluptates molestias. Non hic sit modi minus a. Illum asperiores sed eius dolor impedit animi. Dolor vel fugit voluptas quia voluptatem aut minus.\n\nVelit voluptatum deleniti illo quos omnis deserunt. Omnis consequatur omnis nulla et et. Praesentium dolores rem consequatur laboriosam harum quae. Aut id aliquam nihil consequuntur.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"notes":[]}
+{"id":13628,"milestone_id":null,"group_id":4352,"author_id":1,"assignee_id":null,"iid":2,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.863Z","updated_at":"2019-11-20T17:02:09.863Z","title":"Assumenda possimus sed nostrum consequatur ut sint nihil fugiat.","description":"Culpa fugiat voluptas ut voluptas quo laborum eius. Earum qui dolore temporibus consequatur ratione minima architecto accusantium. Corporis accusantium et consequatur est mollitia sint fugiat aliquam. Est aut quia blanditiis et sint reiciendis. Eveniet accusamus quod molestiae vero hic a ipsum.\n\nNon numquam eum repellendus ipsa tempore necessitatibus. Delectus aut doloremque quis saepe nam ut aut a. Qui corrupti eum animi ipsam. Voluptatem distinctio consequatur accusantium blanditiis.\n\nQuis voluptatum facere inventore itaque quae. Quis quae dolorum autem qui labore. Laboriosam asperiores laborum aperiam voluptatibus error ut quos similique. Deleniti fugit ut eveniet ab quae.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state":"closed","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"notes":[]}
+{"id":13629,"milestone_id":null,"group_id":4352,"author_id":1,"assignee_id":null,"iid":3,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.879Z","updated_at":"2019-11-20T17:02:09.879Z","title":"Ut dolore eos molestiae perferendis quibusdam accusamus.","description":"Possimus vel adipisci consequatur asperiores. Et aspernatur quis ipsum aut natus tempora. Recusandae voluptatibus officiis praesentium et. Nostrum beatae laboriosam dolor nihil ut deserunt ad. Exercitationem iure hic minus deleniti assumenda quis rem.\n\nVoluptate optio et impedit sapiente dignissimos deleniti sit ea. Neque modi voluptates accusamus non non officia sit quis. Qui nihil dolores aut nostrum quia sed dolore perspiciatis. Vero necessitatibus inventore eligendi est aliquid dolorum.\n\nNulla et autem aut fugit aut aut expedita. Molestiae beatae eligendi reiciendis temporibus mollitia aut reprehenderit. Autem maiores rerum dolorum cupiditate. Cum est quasi ab et. Ratione doloribus quas perspiciatis alias voluptates et.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"notes":[]}
+{"id":13630,"milestone_id":null,"group_id":4352,"author_id":1,"assignee_id":null,"iid":4,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.894Z","updated_at":"2019-11-20T17:02:09.894Z","title":"Molestias numquam ut veritatis suscipit eum vitae minima et consequatur sit.","description":"Ad omnis tempore blanditiis vero possimus. Quis quidem et quo cumque pariatur. Nihil eaque inventore natus delectus est qui voluptate. Officiis illo voluptatum aut modi. Inventore voluptate est voluptatem deserunt aut esse.\n\nOdit deserunt ut expedita sit ut. Nam est aut alias quibusdam. Est delectus ratione expedita hic eaque est. Delectus est voluptatibus quo aut dolorem. Libero saepe alias aspernatur itaque et qui.\n\nOmnis voluptas nemo nostrum accusantium. Perspiciatis cupiditate quia quo asperiores. Voluptas perspiciatis nihil officia consectetur recusandae. Libero sed eum laborum expedita quisquam soluta incidunt odit.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state":"closed","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"notes":[]}
+{"id":13631,"milestone_id":null,"group_id":4352,"author_id":1,"assignee_id":null,"iid":5,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.908Z","updated_at":"2019-11-20T17:02:09.908Z","title":"Labore quas voluptas delectus fugiat aut nihil vero.","description":"Necessitatibus aspernatur sunt repellat non animi reprehenderit. Dolor harum ad tempore nesciunt aperiam tenetur. Tempore in est sed quo. Aliquam eaque ullam est consequuntur porro rerum minima aspernatur. Ullam cupiditate illum dicta praesentium assumenda.\n\nEnim impedit ab dolorem libero maiores. Non consectetur ut molestiae quo atque quae necessitatibus. Placeat eveniet minus occaecati magni.\n\nConsequuntur laboriosam quisquam quo eligendi et quia. Sunt ipsam unde adipisci ad praesentium. Odit quia eius quia harum dolor nobis.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"notes":[]}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/labels.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/labels.ndjson
new file mode 100644
index 00000000000..a0a63c14ed7
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/labels.ndjson
@@ -0,0 +1,9 @@
+{"id":23453,"title":"Brire","color":"#d68d9d","project_id":null,"created_at":"2019-11-20T17:02:20.549Z","updated_at":"2019-11-20T17:02:20.549Z","template":false,"description":null,"group_id":4352,"type":"GroupLabel","priorities":[],"textColor":"#333333"}
+{"id":23461,"title":"Cygfunc","color":"#a0695d","project_id":null,"created_at":"2019-11-20T17:02:20.575Z","updated_at":"2019-11-20T17:02:20.575Z","template":false,"description":null,"group_id":4352,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23459,"title":"Cygnix","color":"#691678","project_id":null,"created_at":"2019-11-20T17:02:20.569Z","updated_at":"2019-11-20T17:02:20.569Z","template":false,"description":null,"group_id":4352,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23456,"title":"Genbalt","color":"#7f800c","project_id":null,"created_at":"2019-11-20T17:02:20.560Z","updated_at":"2019-11-20T17:02:20.560Z","template":false,"description":null,"group_id":4352,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23457,"title":"NBP","color":"#e19356","project_id":null,"created_at":"2019-11-20T17:02:20.564Z","updated_at":"2019-11-20T17:02:20.564Z","template":false,"description":null,"group_id":4352,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23455,"title":"Pionce","color":"#65c1b1","project_id":null,"created_at":"2019-11-20T17:02:20.555Z","updated_at":"2019-11-20T17:02:20.555Z","template":false,"description":null,"group_id":4352,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23458,"title":"Pist","color":"#f62da4","project_id":null,"created_at":"2019-11-20T17:02:20.566Z","updated_at":"2019-11-20T17:02:20.566Z","template":false,"description":null,"group_id":4352,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23454,"title":"Poffe","color":"#4f03bc","project_id":null,"created_at":"2019-11-20T17:02:20.552Z","updated_at":"2019-11-20T17:02:20.552Z","template":false,"description":null,"group_id":4352,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23460,"title":"Poune","color":"#036637","project_id":null,"created_at":"2019-11-20T17:02:20.572Z","updated_at":"2019-11-20T17:02:20.572Z","template":false,"description":null,"group_id":4352,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/members.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/members.ndjson
new file mode 100644
index 00000000000..8f9dd22804a
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/members.ndjson
@@ -0,0 +1,6 @@
+{"id":13771,"access_level":30,"source_id":4352,"source_type":"Namespace","user_id":1087,"notification_level":3,"created_at":"2019-11-20T17:04:36.968Z","updated_at":"2019-11-20T17:04:36.968Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":1087,"email":"paige@blanda.info","username":"billi_auer"}}
+{"id":13770,"access_level":20,"source_id":4352,"source_type":"Namespace","user_id":171,"notification_level":3,"created_at":"2019-11-20T17:04:36.821Z","updated_at":"2019-11-20T17:04:36.821Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":171,"email":"heidi@bosco.co.uk","username":"gerard.cruickshank"}}
+{"id":13769,"access_level":30,"source_id":4352,"source_type":"Namespace","user_id":1157,"notification_level":3,"created_at":"2019-11-20T17:04:36.606Z","updated_at":"2019-11-20T17:04:36.606Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":1157,"email":"larisa.bruen@carroll.biz","username":"milagros.reynolds"}}
+{"id":13768,"access_level":40,"source_id":4352,"source_type":"Namespace","user_id":14,"notification_level":3,"created_at":"2019-11-20T17:04:36.465Z","updated_at":"2019-11-20T17:04:36.465Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":14,"email":"madlyn_kovacek@wiza.ca","username":"monique.gusikowski"}}
+{"id":13767,"access_level":10,"source_id":4352,"source_type":"Namespace","user_id":1167,"notification_level":3,"created_at":"2019-11-20T17:04:36.324Z","updated_at":"2019-11-20T17:04:36.324Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":1167,"email":"mirella@koepp.ca","username":"eileen"}}
+{"id":12921,"access_level":50,"source_id":4352,"source_type":"Namespace","user_id":1,"notification_level":3,"created_at":"2019-11-20T17:01:53.953Z","updated_at":"2019-11-20T17:01:53.953Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":1,"email":"admin@example.com","username":"root"}}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/milestones.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/milestones.ndjson
new file mode 100644
index 00000000000..35aa59dc54a
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4352/milestones.ndjson
@@ -0,0 +1,5 @@
+{"id":7647,"title":"v4.0","project_id":null,"description":"Magnam accusantium fuga quo dolorum.","due_date":null,"created_at":"2019-11-20T17:02:14.511Z","updated_at":"2019-11-20T17:02:14.511Z","state":"active","iid":5,"start_date":null,"group_id":4352}
+{"id":7646,"title":"v3.0","project_id":null,"description":"Quasi ut beatae quo vel.","due_date":null,"created_at":"2019-11-20T17:02:14.392Z","updated_at":"2019-11-20T17:02:14.392Z","state":"active","iid":4,"start_date":null,"group_id":4352}
+{"id":7645,"title":"v2.0","project_id":null,"description":"Voluptates et rerum maxime sint cum.","due_date":null,"created_at":"2019-11-20T17:02:14.380Z","updated_at":"2019-11-20T17:02:14.380Z","state":"closed","iid":3,"start_date":null,"group_id":4352}
+{"id":7644,"title":"v1.0","project_id":null,"description":"Qui dolores et facilis corporis dolores.","due_date":null,"created_at":"2019-11-20T17:02:14.364Z","updated_at":"2019-11-20T17:02:14.364Z","state":"active","iid":2,"start_date":null,"group_id":4352}
+{"id":7643,"title":"v0.0","project_id":null,"description":"Et dolor nam rerum culpa nisi doloremque ex.","due_date":null,"created_at":"2019-11-20T17:02:14.351Z","updated_at":"2019-11-20T17:02:14.351Z","state":"active","iid":1,"start_date":null,"group_id":4352}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353.json b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353.json
new file mode 100644
index 00000000000..d0dea3a9ca9
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353.json
@@ -0,0 +1 @@
+{"name":"4n1db5ghlicx3ioddnwftxygq65nxb96dafkf89qp7p9sjqi3p","path":"4n1db5ghlicx3ioddnwftxygq65nxb96dafkf89qp7p9sjqi3p","owner_id":null,"created_at":"2019-11-20 17:01:54 UTC","updated_at":"2019-11-20 17:05:44 UTC","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":0,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":4351,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"saml_discovery_token":"m7cx4AZi","custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"id":4353} \ No newline at end of file
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/badges.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/badges.ndjson
new file mode 100644
index 00000000000..7e0db9da567
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/badges.ndjson
@@ -0,0 +1 @@
+{"id":11,"link_url":"https://localhost:3443/%{default_branch}","image_url":"https://badge_image.png","project_id":null,"group_id":4355,"created_at":"2019-11-20T17:28:11.883Z","updated_at":"2019-11-20T17:28:11.883Z","type":"GroupBadge"}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/boards.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/boards.ndjson
new file mode 100644
index 00000000000..e58ab48806e
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/boards.ndjson
@@ -0,0 +1,2 @@
+{"id":58,"project_id":null,"created_at":"2019-11-20T17:28:15.616Z","updated_at":"2019-11-20T17:28:15.616Z","name":"Development","milestone_id":null,"group_id":4355,"weight":null,"labels":[]}
+{"id":59,"project_id":null,"created_at":"2019-11-20T17:28:25.289Z","updated_at":"2019-11-20T17:28:25.289Z","name":"Sub Board 1","milestone_id":null,"group_id":4355,"weight":null,"labels":[]}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/epics.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/epics.ndjson
new file mode 100644
index 00000000000..0d0a676cf6f
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/epics.ndjson
@@ -0,0 +1,5 @@
+{"id":13642,"milestone_id":null,"group_id":4355,"author_id":1,"assignee_id":null,"iid":1,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:10.151Z","updated_at":"2019-11-20T17:02:10.151Z","title":"Iste qui ratione dolores nisi vel dolor ea totam omnis aut.","description":"Voluptas dolore tenetur repudiandae repellendus maiores beatae quia et. Nisi mollitia exercitationem ut dolores tempore repellat similique. Nesciunt sit occaecati fugiat voluptates qui. Provident quod qui nulla atque dignissimos.\n\nAd veritatis nihil illum nisi est accusamus recusandae. Eos dolore autem ab corporis consectetur officiis ipsum. Consequatur non quis dolor rerum et hic consectetur dicta. Sed aut consectetur mollitia est.\n\nQuia sed dolore culpa error omnis quae quaerat. Magni quos quod illo tempore et eligendi enim. Autem reprehenderit esse vitae aut ipsum consectetur quis.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"notes":[]}
+{"id":13643,"milestone_id":null,"group_id":4355,"author_id":1,"assignee_id":null,"iid":2,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:10.166Z","updated_at":"2019-11-20T17:02:10.166Z","title":"Corporis placeat ut totam impedit ex qui debitis atque et provident.","description":"Quam aut in distinctio ut accusamus aliquam dolor sit. Aliquid quod corporis voluptas aliquam voluptate blanditiis distinctio dolore. Qui quis et qui non sunt deleniti iusto consequatur. Quasi quos omnis nobis et tenetur.\n\nCorrupti eius quod molestias et magnam laboriosam quia quis. Architecto aut eius est voluptas mollitia. Suscipit amet consequatur recusandae natus. Consectetur error quisquam est quas et qui.\n\nRerum earum fugit dolore sunt inventore. Vitae odit tempore autem adipisci voluptate esse placeat nobis. Debitis necessitatibus harum molestiae ex minima tempore consequuntur nihil.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"notes":[]}
+{"id":13644,"milestone_id":null,"group_id":4355,"author_id":1,"assignee_id":null,"iid":3,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:10.180Z","updated_at":"2019-11-20T17:02:10.180Z","title":"Voluptatem incidunt soluta fuga doloribus dolores nisi reiciendis impedit.","description":"Ipsa qui enim deleniti voluptas. Quasi nihil est blanditiis voluptas laudantium cum sequi consequatur. Id quo et atque error et possimus.\n\nUllam ea soluta ipsam sunt veritatis. Et incidunt natus consequatur repellat. Quam molestias magni consequatur soluta aut nobis. Maxime natus aperiam unde recusandae. A in dolorum facilis veniam est.\n\nEx repellendus tempore rem voluptatibus ad culpa consequatur. Consequatur quo quo dolore dicta nostrum necessitatibus tenetur. A voluptatem harum corporis qui quod molestiae culpa.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"notes":[]}
+{"id":13645,"milestone_id":null,"group_id":4355,"author_id":1,"assignee_id":null,"iid":4,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:10.194Z","updated_at":"2019-11-20T17:02:10.194Z","title":"Aut quo veniam soluta veritatis autem doloremque totam qui quia.","description":"Dolor itaque sunt perspiciatis quas natus et praesentium. A sit sapiente dolores ut et dolorum nihil omnis. Dolor quis dolores aut et perferendis.\n\nConsequatur molestiae laboriosam eum consequatur recusandae maxime deleniti commodi. Voluptas voluptatem eaque dicta animi aliquam rerum veritatis. Fugiat consequatur est sit et voluptatem.\n\nSequi tenetur itaque est vero eligendi quia laudantium et. Modi assumenda odio explicabo est non et. Voluptatem et enim minus sit at dicta est.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"notes":[]}
+{"id":13646,"milestone_id":null,"group_id":4355,"author_id":1,"assignee_id":null,"iid":5,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:10.208Z","updated_at":"2019-11-20T17:02:10.208Z","title":"Reprehenderit molestias incidunt non odio laudantium minima eum debitis ipsum.","description":"Quas velit omnis architecto quis eius. Vitae unde velit veniam dolor. Dolores facilis vel repellat et placeat ea rerum ratione. Rem fugit ab assumenda provident vel voluptas harum.\n\nQuia molestias similique illum delectus modi officiis. Aut modi sit ut qui. Est sequi corrupti laudantium ut optio eveniet ut. Corrupti quo provident natus aut omnis nam.\n\nVoluptas facilis repudiandae est quam. Mollitia fugit sint voluptatem aut. Quam quo eligendi id ad perferendis quis magnam. Corrupti sequi vel deleniti odit qui fugit.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"notes":[]}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/labels.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/labels.ndjson
new file mode 100644
index 00000000000..e5ee4a7f2ca
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/labels.ndjson
@@ -0,0 +1,9 @@
+{"id":23488,"title":"Brisync","color":"#66ac54","project_id":null,"created_at":"2019-11-20T17:02:20.654Z","updated_at":"2019-11-20T17:02:20.654Z","template":false,"description":null,"group_id":4355,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23486,"title":"Casync","color":"#2f494d","project_id":null,"created_at":"2019-11-20T17:02:20.648Z","updated_at":"2019-11-20T17:02:20.648Z","template":false,"description":null,"group_id":4355,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23485,"title":"Cygnix","color":"#691678","project_id":null,"created_at":"2019-11-20T17:02:20.646Z","updated_at":"2019-11-20T17:02:20.646Z","template":false,"description":null,"group_id":4355,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23484,"title":"Pynce","color":"#117075","project_id":null,"created_at":"2019-11-20T17:02:20.643Z","updated_at":"2019-11-20T17:02:20.643Z","template":false,"description":null,"group_id":4355,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23490,"title":"Pynswood","color":"#67314e","project_id":null,"created_at":"2019-11-20T17:02:20.663Z","updated_at":"2019-11-20T17:02:20.663Z","template":false,"description":null,"group_id":4355,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23483,"title":"Triffe","color":"#3bf49a","project_id":null,"created_at":"2019-11-20T17:02:20.640Z","updated_at":"2019-11-20T17:02:20.640Z","template":false,"description":null,"group_id":4355,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23489,"title":"Trintforge","color":"#cdab1a","project_id":null,"created_at":"2019-11-20T17:02:20.657Z","updated_at":"2019-11-20T17:02:20.657Z","template":false,"description":null,"group_id":4355,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23482,"title":"Trouffeforge","color":"#db06cb","project_id":null,"created_at":"2019-11-20T17:02:20.637Z","updated_at":"2019-11-20T17:02:20.637Z","template":false,"description":null,"group_id":4355,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23487,"title":"Tryre","color":"#d00c41","project_id":null,"created_at":"2019-11-20T17:02:20.651Z","updated_at":"2019-11-20T17:02:20.651Z","template":false,"description":null,"group_id":4355,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/members.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/members.ndjson
new file mode 100644
index 00000000000..7a36a035c09
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/members.ndjson
@@ -0,0 +1,6 @@
+{"id":13786,"access_level":30,"source_id":4355,"source_type":"Namespace","user_id":1533,"notification_level":3,"created_at":"2019-11-20T17:04:39.405Z","updated_at":"2019-11-20T17:04:39.405Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":1533,"email":"jose@cassin.ca","username":"buster"}}
+{"id":13785,"access_level":10,"source_id":4355,"source_type":"Namespace","user_id":1586,"notification_level":3,"created_at":"2019-11-20T17:04:39.269Z","updated_at":"2019-11-20T17:04:39.269Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":1586,"email":"carie@gleichner.us","username":"dominque"}}
+{"id":13784,"access_level":30,"source_id":4355,"source_type":"Namespace","user_id":190,"notification_level":3,"created_at":"2019-11-20T17:04:39.127Z","updated_at":"2019-11-20T17:04:39.127Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":190,"email":"delois@funk.biz","username":"kittie"}}
+{"id":13783,"access_level":20,"source_id":4355,"source_type":"Namespace","user_id":254,"notification_level":3,"created_at":"2019-11-20T17:04:38.971Z","updated_at":"2019-11-20T17:04:38.971Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":254,"email":"tyra.lowe@whitemckenzie.co.uk","username":"kassie"}}
+{"id":13782,"access_level":40,"source_id":4355,"source_type":"Namespace","user_id":503,"notification_level":3,"created_at":"2019-11-20T17:04:38.743Z","updated_at":"2019-11-20T17:04:38.743Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":503,"email":"tyesha.brakus@bruen.ca","username":"charise"}}
+{"id":12924,"access_level":50,"source_id":4355,"source_type":"Namespace","user_id":1,"notification_level":3,"created_at":"2019-11-20T17:01:54.145Z","updated_at":"2019-11-20T17:01:54.145Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":1,"email":"admin@example.com","username":"root"}}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/milestones.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/milestones.ndjson
new file mode 100644
index 00000000000..cad8ff88d43
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/4353/milestones.ndjson
@@ -0,0 +1,5 @@
+{"id":7662,"title":"v4.0","project_id":null,"description":"Consequatur quaerat aut voluptas repudiandae.","due_date":null,"created_at":"2019-11-20T17:02:14.746Z","updated_at":"2019-11-20T17:02:14.746Z","state":"active","iid":5,"start_date":null,"group_id":4355}
+{"id":7661,"title":"v3.0","project_id":null,"description":"In cupiditate aspernatur non ipsa enim consequatur.","due_date":null,"created_at":"2019-11-20T17:02:14.731Z","updated_at":"2019-11-20T17:02:14.731Z","state":"active","iid":4,"start_date":null,"group_id":4355}
+{"id":7660,"title":"v2.0","project_id":null,"description":"Dolor non rem omnis atque.","due_date":null,"created_at":"2019-11-20T17:02:14.716Z","updated_at":"2019-11-20T17:02:14.716Z","state":"closed","iid":3,"start_date":null,"group_id":4355}
+{"id":7659,"title":"v1.0","project_id":null,"description":"Nihil consectetur et quibusdam esse quae.","due_date":null,"created_at":"2019-11-20T17:02:14.701Z","updated_at":"2019-11-20T17:02:14.701Z","state":"closed","iid":2,"start_date":null,"group_id":4355}
+{"id":7658,"title":"v0.0","project_id":null,"description":"Suscipit dolor id magnam reprehenderit.","due_date":null,"created_at":"2019-11-20T17:02:14.686Z","updated_at":"2019-11-20T17:02:14.686Z","state":"active","iid":1,"start_date":null,"group_id":4355}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/_all.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/_all.ndjson
new file mode 100644
index 00000000000..88195502464
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/complex/tree/groups/_all.ndjson
@@ -0,0 +1,3 @@
+4351
+4352
+4353
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353.json b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353.json
new file mode 100644
index 00000000000..26368307160
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353.json
@@ -0,0 +1,41 @@
+{
+ "id": 4353,
+ "name": "group",
+ "path": "group",
+ "owner_id": null,
+ "created_at": "2019-11-20 17:01:53 UTC",
+ "updated_at": "2019-11-20 17:05:44 UTC",
+ "description": "Group Description",
+ "avatar": {
+ "url": null
+ },
+ "membership_lock": false,
+ "share_with_group_lock": false,
+ "visibility_level": 0,
+ "request_access_enabled": true,
+ "ldap_sync_status": "ready",
+ "ldap_sync_error": null,
+ "ldap_sync_last_update_at": null,
+ "ldap_sync_last_successful_update_at": null,
+ "ldap_sync_last_sync_at": null,
+ "lfs_enabled": null,
+ "parent_id": null,
+ "shared_runners_minutes_limit": null,
+ "repository_size_limit": null,
+ "require_two_factor_authentication": false,
+ "two_factor_grace_period": 48,
+ "plan_id": null,
+ "project_creation_level": 2,
+ "trial_ends_on": null,
+ "file_template_project_id": null,
+ "saml_discovery_token": "rBKx3ioz",
+ "custom_project_templates_group_id": null,
+ "auto_devops_enabled": null,
+ "extra_shared_runners_minutes_limit": null,
+ "last_ci_minutes_notification_at": null,
+ "last_ci_minutes_usage_notification_level": null,
+ "subgroup_creation_level": 1,
+ "emails_disabled": null,
+ "max_pages_size": null,
+ "max_artifacts_size": null
+}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/badges.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/badges.ndjson
new file mode 100644
index 00000000000..3c04897594e
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/badges.ndjson
@@ -0,0 +1 @@
+{"id":10,"link_url":"https://localhost:3443/%{default_branch}","image_url":"https://badge_image.png","project_id":null,"group_id":4351,"created_at":"2019-11-20T17:27:02.047Z","updated_at":"2019-11-20T17:27:02.047Z","type":"GroupBadge"}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/boards.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/boards.ndjson
new file mode 100644
index 00000000000..c3581b5c375
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/boards.ndjson
@@ -0,0 +1,2 @@
+{"id":56,"project_id":null,"created_at":"2019-11-20T17:27:16.808Z","updated_at":"2019-11-20T17:27:16.808Z","name":"Development","milestone_id":null,"group_id":4351,"weight":null,"labels":[]}
+{"id":57,"project_id":null,"created_at":"2019-11-20T17:27:41.118Z","updated_at":"2019-11-20T17:27:41.118Z","name":"Board!","milestone_id":7638,"group_id":4351,"weight":null,"labels":[]}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/epics.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/epics.ndjson
new file mode 100644
index 00000000000..39ac8bbd06c
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/epics.ndjson
@@ -0,0 +1,5 @@
+{"id":13622,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":1,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.754Z","updated_at":"2019-11-20T18:38:40.054Z","title":"Provident neque consequatur numquam ad laboriosam voluptatem magnam.","description":"Fugit nisi est ut numquam quia rerum vitae qui. Et in est aliquid voluptas et ut vitae. In distinctio voluptates ut deleniti iste.\n\nReiciendis eum sunt vero blanditiis at quia. Voluptate eum facilis illum ea distinctio maiores. Doloribus aut nemo ea distinctio.\n\nNihil cum distinctio voluptates quam. Laboriosam distinctio ea accusantium soluta perspiciatis nesciunt impedit. Id qui natus quis minima voluptatum velit ut reprehenderit. Molestiae quia est harum sapiente rem error architecto id. Et minus ipsa et ut ut.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"notes":[{"id":44170,"note":"added epic &5 as child epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:40.031Z","updated_at":"2019-11-20T18:38:40.035Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13622,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"ba005d8dd59cd37a4f32406d46e759b08fd15510","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}},{"id":44168,"note":"added epic &4 as child epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:35.669Z","updated_at":"2019-11-20T18:38:35.673Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13622,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"9b49d3b017aadc1876d477b960e6f8efb99ce29f","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}},{"id":44166,"note":"added epic &3 as child epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:30.944Z","updated_at":"2019-11-20T18:38:30.948Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13622,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"cccfe967f48e699a466c87a55a9f8acb00fec1a1","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}},{"id":44164,"note":"added epic &2 as child epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:26.689Z","updated_at":"2019-11-20T18:38:26.724Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13622,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"133f0c3001860fa8d2031e398a65db74477378c4","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}}]}
+{"id":13623,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":2,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.769Z","updated_at":"2019-11-20T18:38:26.851Z","title":"Omnis accusantium commodi voluptas odio illo eum ut.","description":"Eius vero et iste amet est voluptatem modi. Doloribus ipsam beatae et ut autem ut animi. Dolor culpa dolor omnis delectus est tempora inventore ab. Optio labore tenetur libero quia provident et quis. Blanditiis architecto sint possimus cum aut adipisci.\n\nDolores quisquam sunt cupiditate unde qui vitae nemo. Odio quas omnis ut nobis. Possimus fugit deserunt quia sed ab numquam veritatis nihil.\n\nQui nemo adipisci magnam perferendis voluptatem modi. Eius enim iure dolores consequuntur eum nobis adipisci. Consequatur architecto et quas deleniti hic id laborum officiis. Enim perferendis quis quasi totam delectus rerum deleniti.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":13622,"relative_position":1073741323,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"parent":{"id":13622,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":1,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.754Z","updated_at":"2019-11-20T18:38:40.054Z","title":"Provident neque consequatur numquam ad laboriosam voluptatem magnam.","description":"Fugit nisi est ut numquam quia rerum vitae qui. Et in est aliquid voluptas et ut vitae. In distinctio voluptates ut deleniti iste.\n\nReiciendis eum sunt vero blanditiis at quia. Voluptate eum facilis illum ea distinctio maiores. Doloribus aut nemo ea distinctio.\n\nNihil cum distinctio voluptates quam. Laboriosam distinctio ea accusantium soluta perspiciatis nesciunt impedit. Id qui natus quis minima voluptatum velit ut reprehenderit. Molestiae quia est harum sapiente rem error architecto id. Et minus ipsa et ut ut.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null},"notes":[{"id":44165,"note":"added epic &1 as parent epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:26.822Z","updated_at":"2019-11-20T18:38:26.826Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13623,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"15f0a7f4ed16a07bc78841e122524bb867edcf86","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}}]}
+{"id":13624,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":3,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.783Z","updated_at":"2019-11-20T18:38:31.018Z","title":"Quis dolore velit possimus eaque aut amet esse voluptate aliquam.","description":"Ab veritatis reprehenderit nulla laboriosam et sed asperiores corporis. Est accusantium maxime perferendis et. Omnis a qui voluptates non excepturi.\n\nAdipisci labore maiores dicta sed magnam aut. Veritatis delectus dolorum qui id. Dolorum tenetur quo iure amet. Eveniet reprehenderit dolor ipsam quia ratione quo. Facilis voluptatem vel repellat id illum.\n\nAut et magnam aut minus aspernatur. Fuga quo necessitatibus mollitia maxime quasi. Qui aspernatur quia accusamus est quod. Qui assumenda veritatis dolor non eveniet quibusdam quos qui.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":13622,"relative_position":1073740823,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"parent":{"id":13622,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":1,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.754Z","updated_at":"2019-11-20T18:38:40.054Z","title":"Provident neque consequatur numquam ad laboriosam voluptatem magnam.","description":"Fugit nisi est ut numquam quia rerum vitae qui. Et in est aliquid voluptas et ut vitae. In distinctio voluptates ut deleniti iste.\n\nReiciendis eum sunt vero blanditiis at quia. Voluptate eum facilis illum ea distinctio maiores. Doloribus aut nemo ea distinctio.\n\nNihil cum distinctio voluptates quam. Laboriosam distinctio ea accusantium soluta perspiciatis nesciunt impedit. Id qui natus quis minima voluptatum velit ut reprehenderit. Molestiae quia est harum sapiente rem error architecto id. Et minus ipsa et ut ut.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null},"notes":[{"id":44167,"note":"added epic &1 as parent epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:30.989Z","updated_at":"2019-11-20T18:38:30.993Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13624,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"423ffec14a3ce148c11a802eb1f2613fa8ca9a94","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}}]}
+{"id":13625,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":4,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.798Z","updated_at":"2019-11-20T18:38:35.765Z","title":"Possimus et ut iste temporibus earum cupiditate voluptatem esse assumenda amet.","description":"Et at corporis sed id rerum ullam dolore. Odio magnam corporis excepturi neque est. Est accusamus nostrum qui rerum.\n\nEt aut dolores eaque quibusdam aut quas explicabo id. Est necessitatibus praesentium omnis et vero laboriosam et. Sunt in saepe qui laudantium et voluptas.\n\nVelit sunt odit eum omnis beatae eius aut. Dolores commodi qui impedit deleniti et magnam pariatur. Aut odit amet ipsum ea atque. Itaque est ut sunt ullam eum nam.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":13622,"relative_position":1073740323,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"parent":{"id":13622,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":1,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.754Z","updated_at":"2019-11-20T18:38:40.054Z","title":"Provident neque consequatur numquam ad laboriosam voluptatem magnam.","description":"Fugit nisi est ut numquam quia rerum vitae qui. Et in est aliquid voluptas et ut vitae. In distinctio voluptates ut deleniti iste.\n\nReiciendis eum sunt vero blanditiis at quia. Voluptate eum facilis illum ea distinctio maiores. Doloribus aut nemo ea distinctio.\n\nNihil cum distinctio voluptates quam. Laboriosam distinctio ea accusantium soluta perspiciatis nesciunt impedit. Id qui natus quis minima voluptatum velit ut reprehenderit. Molestiae quia est harum sapiente rem error architecto id. Et minus ipsa et ut ut.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null},"notes":[{"id":44169,"note":"added epic &1 as parent epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:35.737Z","updated_at":"2019-11-20T18:38:35.741Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13625,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"5bc3e30d508affafc61de2b4e1d9f21039505cc3","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}}]}
+{"id":13626,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":5,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.812Z","updated_at":"2019-11-20T18:38:40.101Z","title":"Ab deleniti ipsum voluptatem dolor qui quos saepe repellat quo.","description":"Sunt minus sunt reiciendis culpa sed excepturi. Aperiam sed quod nemo nesciunt et quia molestias incidunt. Ipsum nam magnam labore eos a molestiae rerum possimus. Sequi autem asperiores voluptas assumenda.\n\nRerum ipsa quia cum ab corrupti omnis. Velit libero et nihil ipsa aut quo rem ipsam. Architecto omnis distinctio sed doloribus perspiciatis consequatur aut et. Fugit consequuntur est minima reiciendis reprehenderit et.\n\nConsequatur distinctio et ut blanditiis perferendis officiis inventore. Alias aut voluptatem in facere. Ut perferendis dolorum hic dolores. Ipsa dolorem soluta at mollitia. Placeat et ea numquam dicta molestias.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":13622,"relative_position":1073739823,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null,"parent":{"id":13622,"milestone_id":null,"group_id":4351,"author_id":1,"assignee_id":null,"iid":1,"updated_by_id":null,"last_edited_by_id":null,"lock_version":0,"start_date":null,"end_date":null,"last_edited_at":null,"created_at":"2019-11-20T17:02:09.754Z","updated_at":"2019-11-20T18:38:40.054Z","title":"Provident neque consequatur numquam ad laboriosam voluptatem magnam.","description":"Fugit nisi est ut numquam quia rerum vitae qui. Et in est aliquid voluptas et ut vitae. In distinctio voluptates ut deleniti iste.\n\nReiciendis eum sunt vero blanditiis at quia. Voluptate eum facilis illum ea distinctio maiores. Doloribus aut nemo ea distinctio.\n\nNihil cum distinctio voluptates quam. Laboriosam distinctio ea accusantium soluta perspiciatis nesciunt impedit. Id qui natus quis minima voluptatum velit ut reprehenderit. Molestiae quia est harum sapiente rem error architecto id. Et minus ipsa et ut ut.","start_date_sourcing_milestone_id":null,"due_date_sourcing_milestone_id":null,"start_date_fixed":null,"due_date_fixed":null,"start_date_is_fixed":null,"due_date_is_fixed":null,"closed_by_id":null,"closed_at":null,"parent_id":null,"relative_position":null,"state_id":"opened","start_date_sourcing_epic_id":null,"due_date_sourcing_epic_id":null},"notes":[{"id":44171,"note":"added epic &1 as parent epic","noteable_type":"Epic","author_id":1,"created_at":"2019-11-20T18:38:40.074Z","updated_at":"2019-11-20T18:38:40.077Z","project_id":null,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13626,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":"a6231acdaef5f4d2e569dfb604f1baf85c49e1a0","change_position":null,"resolved_by_push":null,"review_id":null,"type":null,"author":{"name":"Administrator"}}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/labels.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/labels.ndjson
new file mode 100644
index 00000000000..8508c4347c2
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/labels.ndjson
@@ -0,0 +1,10 @@
+{"id":23452,"title":"Bruffefunc","color":"#1d2da4","project_id":null,"created_at":"2019-11-20T17:02:20.546Z","updated_at":"2019-11-20T17:02:20.546Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23446,"title":"Cafunc","color":"#73ed5b","project_id":null,"created_at":"2019-11-20T17:02:20.526Z","updated_at":"2019-11-20T17:02:20.526Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23451,"title":"Casche","color":"#649a75","project_id":null,"created_at":"2019-11-20T17:02:20.544Z","updated_at":"2019-11-20T17:02:20.544Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23444,"title":"Cocell","color":"#1b365c","project_id":null,"created_at":"2019-11-20T17:02:20.521Z","updated_at":"2019-11-20T17:02:20.521Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23449,"title":"Packfunc","color":"#e33bba","project_id":null,"created_at":"2019-11-20T17:02:20.537Z","updated_at":"2019-11-20T17:02:20.537Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23443,"title":"Panabalt","color":"#84f708","project_id":null,"created_at":"2019-11-20T17:02:20.518Z","updated_at":"2019-11-20T17:02:20.518Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23447,"title":"Phierefunc","color":"#4ab4a8","project_id":null,"created_at":"2019-11-20T17:02:20.530Z","updated_at":"2019-11-20T17:02:20.530Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23445,"title":"Pons","color":"#47f440","project_id":null,"created_at":"2019-11-20T17:02:20.523Z","updated_at":"2019-11-20T17:02:20.523Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23448,"title":"Sosync","color":"#110320","project_id":null,"created_at":"2019-11-20T17:02:20.532Z","updated_at":"2019-11-20T17:02:20.532Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
+{"id":23450,"title":"TSL","color":"#58796f","project_id":null,"created_at":"2019-11-20T17:02:20.541Z","updated_at":"2019-11-20T17:02:20.541Z","template":false,"description":null,"group_id":4351,"type":"GroupLabel","priorities":[],"textColor":"#FFFFFF"}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/members.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/members.ndjson
new file mode 100644
index 00000000000..740c724ac5d
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/members.ndjson
@@ -0,0 +1,6 @@
+{"id":13766,"access_level":30,"source_id":4351,"source_type":"Namespace","user_id":42,"notification_level":3,"created_at":"2019-11-20T17:04:36.184Z","updated_at":"2019-11-20T17:04:36.184Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":42,"email":"moriah@collinsmurphy.com","username":"reported_user_15"}}
+{"id":13765,"access_level":40,"source_id":4351,"source_type":"Namespace","user_id":271,"notification_level":3,"created_at":"2019-11-20T17:04:36.044Z","updated_at":"2019-11-20T17:04:36.044Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":271,"email":"garret@connellystark.ca","username":"charlesetta"}}
+{"id":13764,"access_level":30,"source_id":4351,"source_type":"Namespace","user_id":206,"notification_level":3,"created_at":"2019-11-20T17:04:35.840Z","updated_at":"2019-11-20T17:04:35.840Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":206,"email":"margaret.bergnaum@reynolds.us","username":"gwendolyn_robel"}}
+{"id":13763,"access_level":10,"source_id":4351,"source_type":"Namespace","user_id":39,"notification_level":3,"created_at":"2019-11-20T17:04:35.704Z","updated_at":"2019-11-20T17:04:35.704Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":39,"email":"alexis_berge@kerlukeklein.us","username":"reported_user_12"}}
+{"id":13762,"access_level":20,"source_id":4351,"source_type":"Namespace","user_id":1624,"notification_level":3,"created_at":"2019-11-20T17:04:35.566Z","updated_at":"2019-11-20T17:04:35.566Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":1624,"email":"nakesha.herzog@powlowski.com","username":"adriene.mcclure"}}
+{"id":12920,"access_level":50,"source_id":4351,"source_type":"Namespace","user_id":1,"notification_level":3,"created_at":"2019-11-20T17:01:53.505Z","updated_at":"2019-11-20T17:01:53.505Z","created_by_id":null,"invite_email":null,"invite_token":null,"invite_accepted_at":null,"requested_at":null,"expires_at":null,"ldap":false,"override":false,"user":{"id":1,"email":"admin@example.com","username":"root"}}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/milestones.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/milestones.ndjson
new file mode 100644
index 00000000000..40523f276e7
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/4353/milestones.ndjson
@@ -0,0 +1,5 @@
+{"id":7642,"title":"v4.0","project_id":null,"description":"Et laudantium enim omnis ea reprehenderit iure.","due_date":null,"created_at":"2019-11-20T17:02:14.336Z","updated_at":"2019-11-20T17:02:14.336Z","state":"closed","iid":5,"start_date":null,"group_id":4351}
+{"id":7641,"title":"v3.0","project_id":null,"description":"Et repellat culpa nemo consequatur ut reprehenderit.","due_date":null,"created_at":"2019-11-20T17:02:14.323Z","updated_at":"2019-11-20T17:02:14.323Z","state":"active","iid":4,"start_date":null,"group_id":4351}
+{"id":7640,"title":"v2.0","project_id":null,"description":"Velit cupiditate est neque voluptates iste rem sunt.","due_date":null,"created_at":"2019-11-20T17:02:14.309Z","updated_at":"2019-11-20T17:02:14.309Z","state":"active","iid":3,"start_date":null,"group_id":4351}
+{"id":7639,"title":"v1.0","project_id":null,"description":"Amet velit repellat ut rerum aut cum.","due_date":null,"created_at":"2019-11-20T17:02:14.296Z","updated_at":"2019-11-20T17:02:14.296Z","state":"active","iid":2,"start_date":null,"group_id":4351}
+{"id":7638,"title":"v0.0","project_id":null,"description":"Ea quia asperiores ut modi dolorem sunt non numquam.","due_date":null,"created_at":"2019-11-20T17:02:14.282Z","updated_at":"2019-11-20T17:02:14.282Z","state":"active","iid":1,"start_date":null,"group_id":4351}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/_all.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/_all.ndjson
new file mode 100644
index 00000000000..64b4254d985
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree/groups/_all.ndjson
@@ -0,0 +1 @@
+4353
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/283.json b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/283.json
new file mode 100644
index 00000000000..43fba82e87e
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/283.json
@@ -0,0 +1 @@
+{"id":283,"name":"internal","path":"internal","owner_id":null,"created_at":"2020-02-12T16:56:34.924Z","updated_at":"2020-02-12T16:56:38.710Z","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":10,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":null,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"mentions_disabled":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/284.json b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/284.json
new file mode 100644
index 00000000000..376258fb835
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/284.json
@@ -0,0 +1 @@
+{"id":284,"name":"public","path":"public","owner_id":null,"created_at":"2020-02-12T17:33:00.575Z","updated_at":"2020-02-12T17:33:00.575Z","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":20,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":283,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"mentions_disabled":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/285.json b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/285.json
new file mode 100644
index 00000000000..d0539d9d490
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/285.json
@@ -0,0 +1 @@
+{"id":285,"name":"internal","path":"internal","owner_id":null,"created_at":"2020-02-12T17:33:00.575Z","updated_at":"2020-02-12T17:33:00.575Z","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":10,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":283,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"mentions_disabled":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/286.json b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/286.json
new file mode 100644
index 00000000000..aee3de23380
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/286.json
@@ -0,0 +1 @@
+{"id":286,"name":"private","path":"private","owner_id":null,"created_at":"2020-02-12T17:33:00.575Z","updated_at":"2020-02-12T17:33:00.575Z","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":0,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":283,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"mentions_disabled":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/_all.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/_all.ndjson
new file mode 100644
index 00000000000..f289581d271
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/internal/tree/groups/_all.ndjson
@@ -0,0 +1,4 @@
+283
+284
+285
+286
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/283.json b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/283.json
new file mode 100644
index 00000000000..7843315d217
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/283.json
@@ -0,0 +1 @@
+{"id":283,"name":"private","path":"private","owner_id":null,"created_at":"2020-02-12T16:56:34.924Z","updated_at":"2020-02-12T16:56:38.710Z","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":0,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":null,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"mentions_disabled":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/284.json b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/284.json
new file mode 100644
index 00000000000..376258fb835
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/284.json
@@ -0,0 +1 @@
+{"id":284,"name":"public","path":"public","owner_id":null,"created_at":"2020-02-12T17:33:00.575Z","updated_at":"2020-02-12T17:33:00.575Z","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":20,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":283,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"mentions_disabled":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/285.json b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/285.json
new file mode 100644
index 00000000000..d0539d9d490
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/285.json
@@ -0,0 +1 @@
+{"id":285,"name":"internal","path":"internal","owner_id":null,"created_at":"2020-02-12T17:33:00.575Z","updated_at":"2020-02-12T17:33:00.575Z","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":10,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":283,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"mentions_disabled":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/286.json b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/286.json
new file mode 100644
index 00000000000..aee3de23380
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/286.json
@@ -0,0 +1 @@
+{"id":286,"name":"private","path":"private","owner_id":null,"created_at":"2020-02-12T17:33:00.575Z","updated_at":"2020-02-12T17:33:00.575Z","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":0,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":283,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"mentions_disabled":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/_all.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/_all.ndjson
new file mode 100644
index 00000000000..f289581d271
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/private/tree/groups/_all.ndjson
@@ -0,0 +1,4 @@
+283
+284
+285
+286
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/283.json b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/283.json
new file mode 100644
index 00000000000..1148be71cd1
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/283.json
@@ -0,0 +1 @@
+{"id":283,"name":"public","path":"public","owner_id":null,"created_at":"2020-02-12T16:56:34.924Z","updated_at":"2020-02-12T16:56:38.710Z","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":20,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":null,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"mentions_disabled":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/284.json b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/284.json
new file mode 100644
index 00000000000..376258fb835
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/284.json
@@ -0,0 +1 @@
+{"id":284,"name":"public","path":"public","owner_id":null,"created_at":"2020-02-12T17:33:00.575Z","updated_at":"2020-02-12T17:33:00.575Z","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":20,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":283,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"mentions_disabled":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/285.json b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/285.json
new file mode 100644
index 00000000000..d0539d9d490
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/285.json
@@ -0,0 +1 @@
+{"id":285,"name":"internal","path":"internal","owner_id":null,"created_at":"2020-02-12T17:33:00.575Z","updated_at":"2020-02-12T17:33:00.575Z","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":10,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":283,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"mentions_disabled":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/286.json b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/286.json
new file mode 100644
index 00000000000..aee3de23380
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/286.json
@@ -0,0 +1 @@
+{"id":286,"name":"private","path":"private","owner_id":null,"created_at":"2020-02-12T17:33:00.575Z","updated_at":"2020-02-12T17:33:00.575Z","description":"","avatar":{"url":null},"membership_lock":false,"share_with_group_lock":false,"visibility_level":0,"request_access_enabled":true,"ldap_sync_status":"ready","ldap_sync_error":null,"ldap_sync_last_update_at":null,"ldap_sync_last_successful_update_at":null,"ldap_sync_last_sync_at":null,"lfs_enabled":null,"parent_id":283,"shared_runners_minutes_limit":null,"repository_size_limit":null,"require_two_factor_authentication":false,"two_factor_grace_period":48,"plan_id":null,"project_creation_level":2,"trial_ends_on":null,"file_template_project_id":null,"custom_project_templates_group_id":null,"auto_devops_enabled":null,"extra_shared_runners_minutes_limit":null,"last_ci_minutes_notification_at":null,"last_ci_minutes_usage_notification_level":null,"subgroup_creation_level":1,"emails_disabled":null,"max_pages_size":null,"max_artifacts_size":null,"mentions_disabled":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/_all.ndjson b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/_all.ndjson
new file mode 100644
index 00000000000..f289581d271
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/group_exports/visibility_levels/public/tree/groups/_all.ndjson
@@ -0,0 +1,4 @@
+283
+284
+285
+286
diff --git a/spec/fixtures/lib/gitlab/import_export/invalid_json/tree.tar.gz b/spec/fixtures/lib/gitlab/import_export/invalid_json/tree.tar.gz
deleted file mode 100644
index 6524ed5042c..00000000000
--- a/spec/fixtures/lib/gitlab/import_export/invalid_json/tree.tar.gz
+++ /dev/null
Binary files differ
diff --git a/spec/fixtures/lib/gitlab/import_export/invalid_json/tree/project.json b/spec/fixtures/lib/gitlab/import_export/invalid_json/tree/project.json
new file mode 100644
index 00000000000..a5349c5eb85
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/invalid_json/tree/project.json
@@ -0,0 +1 @@
+{"invalid" json}
diff --git a/spec/fixtures/lib/gitlab/import_export/light/tree.tar.gz b/spec/fixtures/lib/gitlab/import_export/light/tree.tar.gz
deleted file mode 100644
index eac19c23b44..00000000000
--- a/spec/fixtures/lib/gitlab/import_export/light/tree.tar.gz
+++ /dev/null
Binary files differ
diff --git a/spec/fixtures/lib/gitlab/import_export/light/tree/project.json b/spec/fixtures/lib/gitlab/import_export/light/tree/project.json
new file mode 100644
index 00000000000..12136c6df3b
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/light/tree/project.json
@@ -0,0 +1 @@
+{"description":"Nisi et repellendus ut enim quo accusamus vel magnam.","import_type":"gitlab_project","creator_id":2147483547,"visibility_level":10,"archived":false,"hooks":[]}
diff --git a/spec/fixtures/lib/gitlab/import_export/light/tree/project/custom_attributes.ndjson b/spec/fixtures/lib/gitlab/import_export/light/tree/project/custom_attributes.ndjson
new file mode 100644
index 00000000000..c1bf6550321
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/light/tree/project/custom_attributes.ndjson
@@ -0,0 +1,2 @@
+{"id":201,"project_id":5,"created_at":"2016-06-14T15:01:51.315Z","updated_at":"2016-06-14T15:01:51.315Z","key":"color","value":"red"}
+{"id":202,"project_id":5,"created_at":"2016-06-14T15:01:51.315Z","updated_at":"2016-06-14T15:01:51.315Z","key":"size","value":"small"}
diff --git a/spec/fixtures/lib/gitlab/import_export/light/tree/project/issues.ndjson b/spec/fixtures/lib/gitlab/import_export/light/tree/project/issues.ndjson
new file mode 100644
index 00000000000..51154e820e6
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/light/tree/project/issues.ndjson
@@ -0,0 +1 @@
+{"id":1,"title":"Fugiat est minima quae maxime non similique.","assignee_id":null,"project_id":8,"author_id":1,"created_at":"2017-07-07T18:13:01.138Z","updated_at":"2017-08-15T18:37:40.807Z","branch_name":null,"description":"Quam totam fuga numquam in eveniet.","state":"opened","iid":20,"updated_by_id":1,"confidential":false,"due_date":null,"moved_to_id":null,"lock_version":null,"time_estimate":0,"closed_at":null,"last_edited_at":null,"last_edited_by_id":null,"group_milestone_id":null,"milestone":{"id":1,"title":"A milestone","group_id":null,"description":"Project-level milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1},"label_links":[{"id":11,"label_id":2,"target_id":1,"target_type":"Issue","created_at":"2017-08-15T18:37:40.795Z","updated_at":"2017-08-15T18:37:40.795Z","label":{"id":6,"title":"Another label","color":"#A8D695","project_id":null,"created_at":"2017-08-15T18:37:19.698Z","updated_at":"2017-08-15T18:37:19.698Z","template":false,"description":"","group_id":null,"type":"ProjectLabel","priorities":[]}}],"notes":[{"id":20,"note":"created merge request !1 to address this issue","noteable_type":"Issue","author_id":1,"created_at":"2020-03-28T01:37:42.307Z","updated_at":"2020-03-28T01:37:42.307Z","project_id":8,"attachment":{"url":null},"line_code":null,"commit_id":null,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":null,"change_position":null,"resolved_by_push":null,"confidential":null,"type":null,"author":{"name":"Author"},"award_emoji":[],"system_note_metadata":{"id":21,"commit_count":null,"action":"merge","created_at":"2020-03-28T01:37:42.307Z","updated_at":"2020-03-28T01:37:42.307Z"},"events":[]}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/light/tree/project/labels.ndjson b/spec/fixtures/lib/gitlab/import_export/light/tree/project/labels.ndjson
new file mode 100644
index 00000000000..28894a8a404
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/light/tree/project/labels.ndjson
@@ -0,0 +1 @@
+{"id":2,"title":"A project label","color":"#428bca","project_id":8,"created_at":"2016-07-22T08:55:44.161Z","updated_at":"2016-07-22T08:55:44.161Z","template":false,"description":"","type":"ProjectLabel","priorities":[{"id":1,"project_id":5,"label_id":1,"priority":1,"created_at":"2016-10-18T09:35:43.338Z","updated_at":"2016-10-18T09:35:43.338Z"}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/light/tree/project/milestones.ndjson b/spec/fixtures/lib/gitlab/import_export/light/tree/project/milestones.ndjson
new file mode 100644
index 00000000000..5158c81db7c
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/light/tree/project/milestones.ndjson
@@ -0,0 +1 @@
+{"id":1,"title":"A milestone","project_id":8,"description":"Project-level milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"group_id":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/light/tree/project/services.ndjson b/spec/fixtures/lib/gitlab/import_export/light/tree/project/services.ndjson
new file mode 100644
index 00000000000..c5ae6bf4b04
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/light/tree/project/services.ndjson
@@ -0,0 +1,2 @@
+{"id":100,"title":"JetBrains TeamCity CI","project_id":5,"created_at":"2016-06-14T15:01:51.315Z","updated_at":"2016-06-14T15:01:51.315Z","active":false,"properties":{},"template":true,"instance":false,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"TeamcityService","category":"ci","default":false,"wiki_page_events":true}
+{"id":101,"title":"Jira","project_id":5,"created_at":"2016-06-14T15:01:51.315Z","updated_at":"2016-06-14T15:01:51.315Z","active":false,"properties":{},"template":false,"instance":true,"push_events":true,"issues_events":true,"merge_requests_events":true,"tag_push_events":true,"note_events":true,"job_events":true,"type":"JiraService","category":"ci","default":false,"wiki_page_events":true}
diff --git a/spec/fixtures/lib/gitlab/import_export/milestone-iid/tree.tar.gz b/spec/fixtures/lib/gitlab/import_export/milestone-iid/tree.tar.gz
deleted file mode 100644
index 726afa0bfa4..00000000000
--- a/spec/fixtures/lib/gitlab/import_export/milestone-iid/tree.tar.gz
+++ /dev/null
Binary files differ
diff --git a/spec/fixtures/lib/gitlab/import_export/milestone-iid/tree/project.json b/spec/fixtures/lib/gitlab/import_export/milestone-iid/tree/project.json
new file mode 100644
index 00000000000..1fed9cc4d2a
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/milestone-iid/tree/project.json
@@ -0,0 +1 @@
+{"id":5,"description":"Nisi et repellendus ut enim quo accusamus vel magnam.","import_type":"gitlab_project","creator_id":123,"visibility_level":10,"archived":false,"hooks":[]}
diff --git a/spec/fixtures/lib/gitlab/import_export/milestone-iid/tree/project/issues.ndjson b/spec/fixtures/lib/gitlab/import_export/milestone-iid/tree/project/issues.ndjson
new file mode 100644
index 00000000000..ea74eb8b379
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/milestone-iid/tree/project/issues.ndjson
@@ -0,0 +1,2 @@
+{"id":1,"title":"Fugiat est minima quae maxime non similique.","assignee_id":null,"project_id":8,"author_id":1,"created_at":"2017-07-07T18:13:01.138Z","updated_at":"2017-08-15T18:37:40.807Z","branch_name":null,"description":"Quam totam fuga numquam in eveniet.","state":"opened","iid":20,"updated_by_id":1,"confidential":false,"due_date":null,"moved_to_id":null,"lock_version":null,"time_estimate":0,"closed_at":null,"last_edited_at":null,"last_edited_by_id":null,"group_milestone_id":null,"milestone":{"id":1,"title":"Group-level milestone","description":"Group-level milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"group_id":8}}
+{"id":2,"title":"est minima quae maxime non similique.","assignee_id":null,"project_id":8,"author_id":1,"created_at":"2017-07-07T18:13:01.138Z","updated_at":"2017-08-15T18:37:40.807Z","branch_name":null,"description":"Quam totam fuga numquam in eveniet.","state":"opened","iid":21,"updated_by_id":1,"confidential":false,"due_date":null,"moved_to_id":null,"lock_version":null,"time_estimate":0,"closed_at":null,"last_edited_at":null,"last_edited_by_id":null,"group_milestone_id":null,"milestone":{"id":2,"title":"Another milestone","project_id":8,"description":"milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"group_id":null}}
diff --git a/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree.tar.gz b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree.tar.gz
deleted file mode 100644
index 13f3d3c6791..00000000000
--- a/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree.tar.gz
+++ /dev/null
Binary files differ
diff --git a/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project.json b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project.json
new file mode 100644
index 00000000000..5f7cf8128bc
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project.json
@@ -0,0 +1 @@
+{"id":5,"approvals_before_merge":0,"archived":false,"auto_cancel_pending_pipelines":"enabled","autoclose_referenced_issues":true,"build_allow_git_fetch":true,"build_coverage_regex":null,"build_timeout":3600,"ci_config_path":null,"delete_error":null,"description":"Vim, Tmux and others","disable_overriding_approvers_per_merge_request":null,"external_authorization_classification_label":"","external_webhook_token":"D3mVYFzZkgZ5kMfcW_wx","public_builds":true,"shared_runners_enabled":true,"visibility_level":20}
diff --git a/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/ci_cd_settings.ndjson b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/ci_cd_settings.ndjson
new file mode 100644
index 00000000000..ab06e07d48d
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/ci_cd_settings.ndjson
@@ -0,0 +1 @@
+{"group_runners_enabled":true}
diff --git a/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/ci_pipelines.ndjson b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/ci_pipelines.ndjson
new file mode 100644
index 00000000000..0c93a83d50d
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/ci_pipelines.ndjson
@@ -0,0 +1,2 @@
+{"before_sha":"0000000000000000000000000000000000000000","committed_at":null,"config_source":"repository_source","created_at":"2020-02-25T12:08:40.615Z","duration":61,"external_pull_request":{"created_at":"2020-02-25T12:08:40.478Z","id":59023,"project_id":17121868,"pull_request_iid":4,"source_branch":"new-branch","source_repository":"liptonshmidt/dotfiles","source_sha":"122bc4bbad5b6448089cacbe16d0bdc3534e7eda","status":"open","target_branch":"master","target_repository":"liptonshmidt/dotfiles","target_sha":"86ebe754fa12216e5c0d9d95890936e2fcc62392","updated_at":"2020-02-25T12:08:40.478Z"},"failure_reason":null,"finished_at":"2020-02-25T12:09:44.464Z","id":120842687,"iid":8,"lock_version":3,"notes":[],"project_id":17121868,"protected":false,"ref":"new-branch","sha":"122bc4bbad5b6448089cacbe16d0bdc3534e7eda","source":"external_pull_request_event","source_sha":"122bc4bbad5b6448089cacbe16d0bdc3534e7eda","stages":[],"started_at":"2020-02-25T12:08:42.511Z","status":"success","tag":false,"target_sha":"86ebe754fa12216e5c0d9d95890936e2fcc62392","updated_at":"2020-02-25T12:09:44.473Z","user_id":4087087,"yaml_errors":null}
+{"before_sha":"86ebe754fa12216e5c0d9d95890936e2fcc62392","committed_at":null,"config_source":"repository_source","created_at":"2020-02-25T12:08:37.434Z","duration":57,"external_pull_request":{"created_at":"2020-02-25T12:08:40.478Z","id":59023,"project_id":17121868,"pull_request_iid":4,"source_branch":"new-branch","source_repository":"liptonshmidt/dotfiles","source_sha":"122bc4bbad5b6448089cacbe16d0bdc3534e7eda","status":"open","target_branch":"master","target_repository":"liptonshmidt/dotfiles","target_sha":"86ebe754fa12216e5c0d9d95890936e2fcc62392","updated_at":"2020-02-25T12:08:40.478Z"},"failure_reason":null,"finished_at":"2020-02-25T12:09:36.557Z","id":120842675,"iid":7,"lock_version":3,"notes":[],"project_id":17121868,"protected":false,"ref":"new-branch","sha":"122bc4bbad5b6448089cacbe16d0bdc3534e7eda","source":"external_pull_request_event","source_sha":"122bc4bbad5b6448089cacbe16d0bdc3534e7eda","stages":[],"started_at":"2020-02-25T12:08:38.682Z","status":"success","tag":false,"target_sha":"86ebe754fa12216e5c0d9d95890936e2fcc62392","updated_at":"2020-02-25T12:09:36.565Z","user_id":4087087,"yaml_errors":null}
diff --git a/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/external_pull_requests.ndjson b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/external_pull_requests.ndjson
new file mode 100644
index 00000000000..421ac662dac
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/external_pull_requests.ndjson
@@ -0,0 +1 @@
+{"created_at":"2020-02-25T12:08:40.478Z","id":59023,"project_id":17121868,"pull_request_iid":4,"source_branch":"new-branch","source_repository":"liptonshmidt/dotfiles","source_sha":"122bc4bbad5b6448089cacbe16d0bdc3534e7eda","status":"open","target_branch":"master","target_repository":"liptonshmidt/dotfiles","target_sha":"86ebe754fa12216e5c0d9d95890936e2fcc62392","updated_at":"2020-02-25T12:08:40.478Z"}
diff --git a/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/project_feature.ndjson b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/project_feature.ndjson
new file mode 100644
index 00000000000..51f4b53b742
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project/project_feature.ndjson
@@ -0,0 +1 @@
+{"builds_access_level":20,"created_at":"2020-02-25T11:20:09.925Z","forking_access_level":20,"id":17494715,"issues_access_level":0,"merge_requests_access_level":0,"pages_access_level":20,"project_id":17121868,"repository_access_level":20,"snippets_access_level":0,"updated_at":"2020-02-25T11:20:10.376Z","wiki_access_level":0}
diff --git a/spec/fixtures/lib/gitlab/import_export/with_invalid_records/tree.tar.gz b/spec/fixtures/lib/gitlab/import_export/with_invalid_records/tree.tar.gz
deleted file mode 100644
index 24c51e72d7d..00000000000
--- a/spec/fixtures/lib/gitlab/import_export/with_invalid_records/tree.tar.gz
+++ /dev/null
Binary files differ
diff --git a/spec/fixtures/lib/gitlab/import_export/with_invalid_records/tree/project.json b/spec/fixtures/lib/gitlab/import_export/with_invalid_records/tree/project.json
new file mode 100644
index 00000000000..c767a8733b4
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/with_invalid_records/tree/project.json
@@ -0,0 +1 @@
+{"id":5,"description":"Nisi et repellendus ut enim quo accusamus vel magnam.","import_type":"gitlab_project","creator_id":999,"visibility_level":10,"archived":false,"hooks":[]}
diff --git a/spec/fixtures/lib/gitlab/import_export/with_invalid_records/tree/project/milestones.ndjson b/spec/fixtures/lib/gitlab/import_export/with_invalid_records/tree/project/milestones.ndjson
new file mode 100644
index 00000000000..28e737fa43c
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/with_invalid_records/tree/project/milestones.ndjson
@@ -0,0 +1,2 @@
+{"id":1,"title":null,"project_id":8,"description":123,"due_date":null,"created_at":"NOT A DATE","updated_at":"NOT A DATE","state":"active","iid":1,"group_id":null}
+{"id":42,"title":"A valid milestone","project_id":8,"description":"Project-level milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"group_id":null}
diff --git a/spec/fixtures/lib/gitlab/metrics/dashboard/development_metrics.yml b/spec/fixtures/lib/gitlab/metrics/dashboard/development_metrics.yml
new file mode 100644
index 00000000000..083261a5574
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/metrics/dashboard/development_metrics.yml
@@ -0,0 +1,39 @@
+panel_groups:
+ - group: 'Usage Variation'
+ panels:
+ - type: anomaly-chart
+ title: "Memory Usage Rate Anomalies"
+ y_label: "Memory Usage Rate"
+ metrics:
+ - id: container_memory_usage_bytes
+ query_range: avg(sum(rate(container_memory_usage_bytes[15m]))) /1024
+ label: "Memory Usage Rate"
+ unit: "kB"
+ - id: container_memory_usage_bytes_upper
+ query_range: 80000
+ label: "Memory Usage Rate Lower Limit"
+ unit: "kB"
+ - id: container_memory_usage_bytes_lower
+ query_range: 50000
+ label: "Memory Usage Rate Upper Limit"
+ unit: "kB"
+ - group: System metrics (Kubernetes)
+ panels:
+ - title: 'Container CPU Usage by Environment (seconds)'
+ type: 'heatmap'
+ metrics:
+ - id: container_cpu_usage_by_env
+ query_range: 'sum(rate(container_cpu_usage_seconds_total{environment=~"coredns|production|kube|kube-controller"}[1h])) by (environment)'
+ step: 3600
+ - title: 'Number of GitLab Runner requests by status'
+ type: 'heatmap'
+ metrics:
+ - id: number_of_runner_requests_by_status
+ query_range: 'sum(rate(gitlab_runner_api_request_statuses_total[60m])) by (status)'
+ step: 3600
+ - title: '95 percentile of request durations per handler (seconds)'
+ type: 'heatmap'
+ metrics:
+ - id: 95_percentile_of_request_durations_per_handler
+ query_range: 'histogram_quantile(0.95, sum(rate(prometheus_http_request_duration_seconds_bucket[1h])) by (handler,le))'
+ step: 3600
diff --git a/spec/fixtures/lsif.json.zip b/spec/fixtures/lsif.json.zip
new file mode 100644
index 00000000000..a65457664b9
--- /dev/null
+++ b/spec/fixtures/lsif.json.zip
Binary files differ
diff --git a/spec/fixtures/sample_doc.md b/spec/fixtures/sample_doc.md
new file mode 100644
index 00000000000..84080dd1089
--- /dev/null
+++ b/spec/fixtures/sample_doc.md
@@ -0,0 +1 @@
+[GitLab API](api/README.md)
diff --git a/spec/fixtures/terraform/tfplan.json b/spec/fixtures/terraform/tfplan.json
new file mode 100644
index 00000000000..0ab4891e63a
--- /dev/null
+++ b/spec/fixtures/terraform/tfplan.json
@@ -0,0 +1 @@
+{"create": 0, "update": 1, "delete": 0}
diff --git a/spec/fixtures/terraform/tfplan_with_corrupted_data.json b/spec/fixtures/terraform/tfplan_with_corrupted_data.json
new file mode 100644
index 00000000000..b83f5e172bb
--- /dev/null
+++ b/spec/fixtures/terraform/tfplan_with_corrupted_data.json
@@ -0,0 +1 @@
+Exited code 1
diff --git a/spec/fixtures/trace/sample_trace b/spec/fixtures/trace/sample_trace
index e76712782be..e9d1e79fc71 100644
--- a/spec/fixtures/trace/sample_trace
+++ b/spec/fixtures/trace/sample_trace
@@ -2768,10 +2768,6 @@ Service
updates the has_external_issue_tracker boolean
on update
updates the has_external_issue_tracker boolean
- #deprecated?
- should return false by default
- #deprecation_message
- should be empty by default
#api_field_names
filters out sensitive fields
diff --git a/spec/fixtures/x509/ZZZZZZA6.crl b/spec/fixtures/x509/ZZZZZZA6.crl
new file mode 100644
index 00000000000..eb6b9d5d71a
--- /dev/null
+++ b/spec/fixtures/x509/ZZZZZZA6.crl
Binary files differ
diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml
index c8aacca5ef2..b9159191114 100644
--- a/spec/frontend/.eslintrc.yml
+++ b/spec/frontend/.eslintrc.yml
@@ -1,10 +1,6 @@
---
-env:
- jest/globals: true
-plugins:
- - jest
extends:
- - 'plugin:jest/recommended'
+ - 'plugin:@gitlab/jest'
settings:
# We have to teach eslint-plugin-import what node modules we use
# otherwise there is an error when it tries to resolve them
@@ -14,9 +10,18 @@ settings:
- path
import/resolver:
jest:
- jestConfigFile: 'jest.config.js'
+ jestConfigFile: 'jest.config.unit.js'
globals:
getJSONFixture: false
loadFixtures: false
preloadFixtures: false
setFixtures: false
+rules:
+ jest/expect-expect:
+ - off
+ - assertFunctionNames:
+ - 'expect*'
+ - 'assert*'
+ - 'testAction'
+ jest/no-test-callback:
+ - off
diff --git a/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js b/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js
new file mode 100644
index 00000000000..726ed0fa030
--- /dev/null
+++ b/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js
@@ -0,0 +1,29 @@
+export const Editor = {
+ props: {
+ initialValue: {
+ type: String,
+ required: true,
+ },
+ options: {
+ type: Object,
+ },
+ initialEditType: {
+ type: String,
+ },
+ height: {
+ type: String,
+ },
+ previewStyle: {
+ type: String,
+ },
+ },
+ render(h) {
+ return h('div');
+ },
+};
+
+export const Viewer = {
+ render(h) {
+ return h('div');
+ },
+};
diff --git a/spec/frontend/ajax_loading_spinner_spec.js b/spec/frontend/ajax_loading_spinner_spec.js
new file mode 100644
index 00000000000..8ed2ee49ff8
--- /dev/null
+++ b/spec/frontend/ajax_loading_spinner_spec.js
@@ -0,0 +1,57 @@
+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_management/components/alert_management_detail_spec.js b/spec/frontend/alert_management/components/alert_management_detail_spec.js
new file mode 100644
index 00000000000..1e4c2e24ccb
--- /dev/null
+++ b/spec/frontend/alert_management/components/alert_management_detail_spec.js
@@ -0,0 +1,242 @@
+import { mount, shallowMount } from '@vue/test-utils';
+import { GlAlert, GlLoadingIcon, GlDropdownItem, GlTable } from '@gitlab/ui';
+import AlertDetails from '~/alert_management/components/alert_details.vue';
+import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql';
+import createFlash from '~/flash';
+
+import mockAlerts from '../mocks/alerts.json';
+
+const mockAlert = mockAlerts[0];
+jest.mock('~/flash');
+
+describe('AlertDetails', () => {
+ let wrapper;
+ const newIssuePath = 'root/alerts/-/issues/new';
+ const findStatusDropdownItem = () => wrapper.find(GlDropdownItem);
+ const findDetailsTable = () => wrapper.find(GlTable);
+
+ function mountComponent({
+ data,
+ createIssueFromAlertEnabled = false,
+ loading = false,
+ mountMethod = shallowMount,
+ stubs = {},
+ } = {}) {
+ wrapper = mountMethod(AlertDetails, {
+ propsData: {
+ alertId: 'alertId',
+ projectPath: 'projectPath',
+ newIssuePath,
+ },
+ data() {
+ return { alert: { ...mockAlert }, ...data };
+ },
+ provide: {
+ glFeatures: { createIssueFromAlertEnabled },
+ },
+ mocks: {
+ $apollo: {
+ mutate: jest.fn(),
+ queries: {
+ alert: {
+ loading,
+ },
+ },
+ },
+ },
+ stubs,
+ });
+ }
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ const findCreatedIssueBtn = () => wrapper.find('[data-testid="createIssueBtn"]');
+
+ describe('Alert details', () => {
+ describe('when alert is null', () => {
+ beforeEach(() => {
+ mountComponent({ data: { alert: null } });
+ });
+
+ it('shows an empty state', () => {
+ expect(wrapper.find('[data-testid="alertDetailsTabs"]').exists()).toBe(false);
+ });
+ });
+
+ describe('when alert is present', () => {
+ beforeEach(() => {
+ mountComponent({ data: { alert: mockAlert } });
+ });
+
+ it('renders a tab with overview information', () => {
+ expect(wrapper.find('[data-testid="overviewTab"]').exists()).toBe(true);
+ });
+
+ it('renders a tab with full alert information', () => {
+ expect(wrapper.find('[data-testid="fullDetailsTab"]').exists()).toBe(true);
+ });
+
+ it('renders a title', () => {
+ expect(wrapper.find('[data-testid="title"]').text()).toBe(mockAlert.title);
+ });
+
+ it('renders a start time', () => {
+ expect(wrapper.find('[data-testid="startTimeItem"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="startTimeItem"]').props().time).toBe(
+ mockAlert.startedAt,
+ );
+ });
+ });
+
+ describe('individual alert fields', () => {
+ describe.each`
+ field | data | isShown
+ ${'eventCount'} | ${1} | ${true}
+ ${'eventCount'} | ${undefined} | ${false}
+ ${'monitoringTool'} | ${'New Relic'} | ${true}
+ ${'monitoringTool'} | ${undefined} | ${false}
+ ${'service'} | ${'Prometheus'} | ${true}
+ ${'service'} | ${undefined} | ${false}
+ `(`$desc`, ({ field, data, isShown }) => {
+ beforeEach(() => {
+ mountComponent({ data: { alert: { ...mockAlert, [field]: data } } });
+ });
+
+ it(`${field} is ${isShown ? 'displayed' : 'hidden'} correctly`, () => {
+ if (isShown) {
+ expect(wrapper.find(`[data-testid="${field}"]`).text()).toBe(data.toString());
+ } else {
+ expect(wrapper.find(`[data-testid="${field}"]`).exists()).toBe(false);
+ }
+ });
+ });
+ });
+
+ describe('Create issue from alert', () => {
+ describe('createIssueFromAlertEnabled feature flag enabled', () => {
+ it('should display a button that links to new issue page', () => {
+ mountComponent({ createIssueFromAlertEnabled: true });
+ expect(findCreatedIssueBtn().exists()).toBe(true);
+ expect(findCreatedIssueBtn().attributes('href')).toBe(newIssuePath);
+ });
+ });
+
+ describe('createIssueFromAlertEnabled feature flag disabled', () => {
+ it('should display a button that links to a new issue page', () => {
+ mountComponent({ createIssueFromAlertEnabled: false });
+ expect(findCreatedIssueBtn().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('View full alert details', () => {
+ beforeEach(() => {
+ mountComponent({ data: { alert: mockAlert } });
+ });
+ it('should display a table of raw alert details data', () => {
+ wrapper.find('[data-testid="fullDetailsTab"]').trigger('click');
+ expect(findDetailsTable().exists()).toBe(true);
+ });
+ });
+
+ describe('loading state', () => {
+ beforeEach(() => {
+ mountComponent({ loading: true });
+ });
+
+ it('displays a loading state when loading', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('error state', () => {
+ it('displays a error state correctly', () => {
+ mountComponent({ data: { errored: true } });
+ expect(wrapper.find(GlAlert).exists()).toBe(true);
+ });
+
+ it('does not display an error when dismissed', () => {
+ mountComponent({ data: { errored: true, isErrorDismissed: true } });
+ expect(wrapper.find(GlAlert).exists()).toBe(false);
+ });
+ });
+
+ describe('header', () => {
+ const findHeader = () => wrapper.find('[data-testid="alert-header"]');
+ const stubs = { TimeAgoTooltip: '<span>now</span>' };
+
+ describe('individual header fields', () => {
+ describe.each`
+ severity | createdAt | monitoringTool | result
+ ${'MEDIUM'} | ${'2020-04-17T23:18:14.996Z'} | ${null} | ${'Medium • Reported now'}
+ ${'INFO'} | ${'2020-04-17T23:18:14.996Z'} | ${'Datadog'} | ${'Info • Reported now by Datadog'}
+ `(
+ `When severity=$severity, createdAt=$createdAt, monitoringTool=$monitoringTool`,
+ ({ severity, createdAt, monitoringTool, result }) => {
+ beforeEach(() => {
+ mountComponent({
+ data: { alert: { ...mockAlert, severity, createdAt, monitoringTool } },
+ mountMethod: mount,
+ stubs,
+ });
+ });
+
+ it('header text is shown correctly', () => {
+ expect(findHeader().text()).toBe(result);
+ });
+ },
+ );
+ });
+ });
+ });
+
+ describe('updating the alert status', () => {
+ const mockUpdatedMutationResult = {
+ data: {
+ updateAlertStatus: {
+ errors: [],
+ alert: {
+ status: 'acknowledged',
+ },
+ },
+ },
+ };
+
+ beforeEach(() => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alert: mockAlert },
+ loading: false,
+ });
+ });
+
+ it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
+ findStatusDropdownItem().vm.$emit('click');
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: updateAlertStatus,
+ variables: {
+ iid: 'alertId',
+ status: 'TRIGGERED',
+ projectPath: 'projectPath',
+ },
+ });
+ });
+
+ it('calls `createFlash` when request fails', () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
+ findStatusDropdownItem().vm.$emit('click');
+
+ setImmediate(() => {
+ expect(createFlash).toHaveBeenCalledWith(
+ 'There was an error while updating the status of the alert. Please try again.',
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/alert_management/components/alert_management_list_spec.js b/spec/frontend/alert_management/components/alert_management_list_spec.js
new file mode 100644
index 00000000000..c4630ac57fe
--- /dev/null
+++ b/spec/frontend/alert_management/components/alert_management_list_spec.js
@@ -0,0 +1,325 @@
+import { mount } from '@vue/test-utils';
+import {
+ GlEmptyState,
+ GlTable,
+ GlAlert,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlTab,
+} from '@gitlab/ui';
+import { visitUrl } from '~/lib/utils/url_utility';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import createFlash from '~/flash';
+import AlertManagementList from '~/alert_management/components/alert_management_list.vue';
+import { ALERTS_STATUS_TABS } from '../../../../app/assets/javascripts/alert_management/constants';
+import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql';
+import mockAlerts from '../mocks/alerts.json';
+
+jest.mock('~/flash');
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn().mockName('visitUrlMock'),
+ joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
+}));
+
+describe('AlertManagementList', () => {
+ let wrapper;
+
+ const findAlertsTable = () => wrapper.find(GlTable);
+ const findAlerts = () => wrapper.findAll('table tbody tr');
+ const findAlert = () => wrapper.find(GlAlert);
+ const findLoader = () => wrapper.find(GlLoadingIcon);
+ const findStatusDropdown = () => wrapper.find(GlDropdown);
+ const findStatusFilterTabs = () => wrapper.findAll(GlTab);
+ const findDateFields = () => wrapper.findAll(TimeAgo);
+ const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem);
+ const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]');
+
+ function mountComponent({
+ props = {
+ alertManagementEnabled: false,
+ userCanEnableAlertManagement: false,
+ },
+ data = {},
+ loading = false,
+ alertListStatusFilteringEnabled = false,
+ stubs = {},
+ } = {}) {
+ wrapper = mount(AlertManagementList, {
+ propsData: {
+ projectPath: 'gitlab-org/gitlab',
+ enableAlertManagementPath: '/link',
+ emptyAlertSvgPath: 'illustration/path',
+ ...props,
+ },
+ provide: {
+ glFeatures: {
+ alertListStatusFilteringEnabled,
+ },
+ },
+ data() {
+ return data;
+ },
+ mocks: {
+ $apollo: {
+ mutate: jest.fn(),
+ queries: {
+ alerts: {
+ loading,
+ },
+ },
+ },
+ },
+ stubs,
+ });
+ }
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ describe('alert management feature renders empty state', () => {
+ it('shows empty state', () => {
+ expect(wrapper.find(GlEmptyState).exists()).toBe(true);
+ });
+ });
+
+ describe('Status Filter Tabs', () => {
+ describe('alertListStatusFilteringEnabled feature flag enabled', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts },
+ loading: false,
+ alertListStatusFilteringEnabled: true,
+ stubs: {
+ GlTab: true,
+ },
+ });
+ });
+
+ it('should display filter tabs for all statuses', () => {
+ const tabs = findStatusFilterTabs().wrappers;
+ tabs.forEach((tab, i) => {
+ expect(tab.text()).toContain(ALERTS_STATUS_TABS[i].title);
+ });
+ });
+ });
+
+ describe('alertListStatusFilteringEnabled feature flag disabled', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts },
+ loading: false,
+ alertListStatusFilteringEnabled: false,
+ stubs: {
+ GlTab: true,
+ },
+ });
+ });
+
+ it('should NOT display tabs', () => {
+ expect(findStatusFilterTabs()).not.toExist();
+ });
+ });
+ });
+
+ describe('Alerts table', () => {
+ it('loading state', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: null },
+ loading: true,
+ });
+ expect(findAlertsTable().exists()).toBe(true);
+ expect(findLoader().exists()).toBe(true);
+ });
+
+ it('error state', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: null, errored: true },
+ loading: false,
+ });
+ expect(findAlertsTable().exists()).toBe(true);
+ expect(findAlertsTable().text()).toContain('No alerts to display');
+ expect(findLoader().exists()).toBe(false);
+ expect(findAlert().props().variant).toBe('danger');
+ });
+
+ it('empty state', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: [], errored: false },
+ loading: false,
+ });
+ expect(findAlertsTable().exists()).toBe(true);
+ expect(findAlertsTable().text()).toContain('No alerts to display');
+ expect(findLoader().exists()).toBe(false);
+ expect(findAlert().props().variant).toBe('info');
+ });
+
+ it('has data state', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+ expect(findLoader().exists()).toBe(false);
+ expect(findAlertsTable().exists()).toBe(true);
+ expect(findAlerts()).toHaveLength(mockAlerts.length);
+ });
+
+ it('displays status dropdown', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+ expect(findStatusDropdown().exists()).toBe(true);
+ });
+
+ it('shows correct severity icons', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(GlTable).exists()).toBe(true);
+ expect(
+ findAlertsTable()
+ .find(GlIcon)
+ .classes('icon-critical'),
+ ).toBe(true);
+ });
+ });
+
+ it('renders severity text', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+
+ expect(
+ findSeverityFields()
+ .at(0)
+ .text(),
+ ).toBe('Critical');
+ });
+
+ it('navigates to the detail page when alert row is clicked', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+
+ findAlerts()
+ .at(0)
+ .trigger('click');
+ expect(visitUrl).toHaveBeenCalledWith('/1527542/details');
+ });
+
+ describe('handle date fields', () => {
+ it('should display time ago dates when values provided', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: {
+ alerts: [
+ {
+ iid: 1,
+ status: 'acknowledged',
+ startedAt: '2020-03-17T23:18:14.996Z',
+ endedAt: '2020-04-17T23:18:14.996Z',
+ severity: 'high',
+ },
+ ],
+ errored: false,
+ },
+ loading: false,
+ });
+ expect(findDateFields().length).toBe(2);
+ });
+
+ it('should not display time ago dates when values not provided', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: {
+ alerts: [
+ {
+ iid: 1,
+ status: 'acknowledged',
+ startedAt: null,
+ endedAt: null,
+ severity: 'high',
+ },
+ ],
+ errored: false,
+ },
+ loading: false,
+ });
+ expect(findDateFields().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('updating the alert status', () => {
+ const iid = '1527542';
+ const mockUpdatedMutationResult = {
+ data: {
+ updateAlertStatus: {
+ errors: [],
+ alert: {
+ iid,
+ status: 'acknowledged',
+ },
+ },
+ },
+ };
+
+ beforeEach(() => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+ });
+
+ it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
+ findFirstStatusOption().vm.$emit('click');
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: updateAlertStatus,
+ variables: {
+ iid,
+ status: 'TRIGGERED',
+ projectPath: 'gitlab-org/gitlab',
+ },
+ });
+ });
+
+ it('calls `createFlash` when request fails', () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
+ findFirstStatusOption().vm.$emit('click');
+
+ setImmediate(() => {
+ expect(createFlash).toHaveBeenCalledWith(
+ 'There was an error while updating the status of the alert. Please try again.',
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/alert_management/mocks/alerts.json b/spec/frontend/alert_management/mocks/alerts.json
new file mode 100644
index 00000000000..b67e2cfc52e
--- /dev/null
+++ b/spec/frontend/alert_management/mocks/alerts.json
@@ -0,0 +1,29 @@
+[
+ {
+ "iid": "1527542",
+ "title": "SyntaxError: Invalid or unexpected token",
+ "severity": "CRITICAL",
+ "eventCount": 7,
+ "startedAt": "2020-04-17T23:18:14.996Z",
+ "endedAt": "2020-04-17T23:18:14.996Z",
+ "status": "TRIGGERED"
+ },
+ {
+ "iid": "1527543",
+ "title": "Some other alert Some other alert Some other alert Some other alert Some other alert Some other alert",
+ "severity": "MEDIUM",
+ "eventCount": 1,
+ "startedAt": "2020-04-17T23:18:14.996Z",
+ "endedAt": "2020-04-17T23:18:14.996Z",
+ "status": "ACKNOWLEDGED"
+ },
+ {
+ "iid": "1527544",
+ "title": "SyntaxError: Invalid or unexpected token",
+ "severity": "LOW",
+ "eventCount": 4,
+ "startedAt": "2020-04-17T23:18:14.996Z",
+ "endedAt": "2020-04-17T23:18:14.996Z",
+ "status": "RESOLVED"
+ }
+ ]
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index f34c2fb69eb..d365048ab0b 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -15,7 +15,7 @@ describe('Api', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
originalGon = window.gon;
- window.gon = Object.assign({}, dummyGon);
+ window.gon = { ...dummyGon };
});
afterEach(() => {
diff --git a/spec/frontend/autosave_spec.js b/spec/frontend/autosave_spec.js
index 3119477f385..bbdf3c6f91d 100644
--- a/spec/frontend/autosave_spec.js
+++ b/spec/frontend/autosave_spec.js
@@ -10,6 +10,8 @@ describe('Autosave', () => {
const field = $('<textarea></textarea>');
const key = 'key';
const fallbackKey = 'fallbackKey';
+ const lockVersionKey = 'lockVersionKey';
+ const lockVersion = 1;
describe('class constructor', () => {
beforeEach(() => {
@@ -30,6 +32,13 @@ describe('Autosave', () => {
expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
expect(autosave.isLocalStorageAvailable).toBe(true);
});
+
+ it('should set .isLocalStorageAvailable if lockVersion is passed', () => {
+ autosave = new Autosave(field, key, null, lockVersion);
+
+ expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
+ expect(autosave.isLocalStorageAvailable).toBe(true);
+ });
});
describe('restore', () => {
@@ -96,6 +105,40 @@ describe('Autosave', () => {
});
});
+ describe('getSavedLockVersion', () => {
+ beforeEach(() => {
+ autosave = {
+ field,
+ key,
+ lockVersionKey,
+ };
+ });
+
+ describe('if .isLocalStorageAvailable is `false`', () => {
+ beforeEach(() => {
+ autosave.isLocalStorageAvailable = false;
+
+ Autosave.prototype.getSavedLockVersion.call(autosave);
+ });
+
+ it('should not call .getItem', () => {
+ expect(window.localStorage.getItem).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('if .isLocalStorageAvailable is `true`', () => {
+ beforeEach(() => {
+ autosave.isLocalStorageAvailable = true;
+ });
+
+ it('should call .getItem', () => {
+ Autosave.prototype.getSavedLockVersion.call(autosave);
+
+ expect(window.localStorage.getItem).toHaveBeenCalledWith(lockVersionKey);
+ });
+ });
+ });
+
describe('save', () => {
beforeEach(() => {
autosave = { reset: jest.fn() };
@@ -128,10 +171,51 @@ describe('Autosave', () => {
});
});
+ describe('save with lockVersion', () => {
+ beforeEach(() => {
+ autosave = {
+ field,
+ key,
+ lockVersionKey,
+ lockVersion,
+ isLocalStorageAvailable: true,
+ };
+ });
+
+ describe('lockVersion is valid', () => {
+ it('should call .setItem', () => {
+ Autosave.prototype.save.call(autosave);
+ expect(window.localStorage.setItem).toHaveBeenCalledWith(lockVersionKey, lockVersion);
+ });
+
+ it('should call .setItem when version is 0', () => {
+ autosave.lockVersion = 0;
+ Autosave.prototype.save.call(autosave);
+ expect(window.localStorage.setItem).toHaveBeenCalledWith(
+ lockVersionKey,
+ autosave.lockVersion,
+ );
+ });
+ });
+
+ describe('lockVersion is invalid', () => {
+ it('should not call .setItem with lockVersion', () => {
+ delete autosave.lockVersion;
+ Autosave.prototype.save.call(autosave);
+
+ expect(window.localStorage.setItem).not.toHaveBeenCalledWith(
+ lockVersionKey,
+ autosave.lockVersion,
+ );
+ });
+ });
+ });
+
describe('reset', () => {
beforeEach(() => {
autosave = {
key,
+ lockVersionKey,
};
});
@@ -156,6 +240,7 @@ describe('Autosave', () => {
it('should call .removeItem', () => {
expect(window.localStorage.removeItem).toHaveBeenCalledWith(key);
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith(lockVersionKey);
});
});
});
@@ -166,8 +251,8 @@ describe('Autosave', () => {
field,
key,
fallbackKey,
+ isLocalStorageAvailable: true,
};
- autosave.isLocalStorageAvailable = true;
});
it('should call .getItem', () => {
@@ -185,7 +270,8 @@ describe('Autosave', () => {
it('should call .removeItem for key and fallbackKey', () => {
Autosave.prototype.reset.call(autosave);
- expect(window.localStorage.removeItem).toHaveBeenCalledTimes(2);
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith(fallbackKey);
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith(key);
});
});
});
diff --git a/spec/frontend/avatar_helper_spec.js b/spec/frontend/avatar_helper_spec.js
new file mode 100644
index 00000000000..c4da7189751
--- /dev/null
+++ b/spec/frontend/avatar_helper_spec.js
@@ -0,0 +1,110 @@
+import { TEST_HOST } from 'spec/test_constants';
+import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility';
+import {
+ DEFAULT_SIZE_CLASS,
+ IDENTICON_BG_COUNT,
+ renderAvatar,
+ renderIdenticon,
+ getIdenticonBackgroundClass,
+ getIdenticonTitle,
+} from '~/helpers/avatar_helper';
+
+function matchAll(str) {
+ return new RegExp(`^${str}$`);
+}
+
+describe('avatar_helper', () => {
+ describe('getIdenticonBackgroundClass', () => {
+ it('returns identicon bg class from id that is a number', () => {
+ expect(getIdenticonBackgroundClass(1)).toEqual('bg2');
+ });
+
+ it('returns identicon bg class from id that is a string', () => {
+ expect(getIdenticonBackgroundClass('1')).toEqual('bg2');
+ });
+
+ it('returns identicon bg class from id that is a GraphQL string id', () => {
+ expect(getIdenticonBackgroundClass('gid://gitlab/Project/1')).toEqual('bg2');
+ });
+
+ it('returns identicon bg class from unparsable string', () => {
+ expect(getIdenticonBackgroundClass('gid://gitlab/')).toEqual('bg1');
+ });
+
+ it(`wraps around if id is bigger than ${IDENTICON_BG_COUNT}`, () => {
+ expect(getIdenticonBackgroundClass(IDENTICON_BG_COUNT + 4)).toEqual('bg5');
+ expect(getIdenticonBackgroundClass(IDENTICON_BG_COUNT * 5 + 6)).toEqual('bg7');
+ });
+ });
+
+ describe('getIdenticonTitle', () => {
+ it('returns identicon title from name', () => {
+ expect(getIdenticonTitle('Lorem')).toEqual('L');
+ expect(getIdenticonTitle('dolar-sit-amit')).toEqual('D');
+ expect(getIdenticonTitle('%-with-special-chars')).toEqual('%');
+ });
+
+ it('returns space if name is falsey', () => {
+ expect(getIdenticonTitle('')).toEqual(' ');
+ expect(getIdenticonTitle(null)).toEqual(' ');
+ });
+ });
+
+ describe('renderIdenticon', () => {
+ it('renders with the first letter as title and bg based on id', () => {
+ const entity = {
+ id: IDENTICON_BG_COUNT + 3,
+ name: 'Xavior',
+ };
+ const options = {
+ sizeClass: 's32',
+ };
+
+ const result = renderIdenticon(entity, options);
+
+ expect(result).toHaveClass(`identicon ${options.sizeClass} bg4`);
+ expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name)));
+ });
+
+ it('renders with defaults, if no options are given', () => {
+ const entity = {
+ id: 1,
+ name: 'tanuki',
+ };
+
+ const result = renderIdenticon(entity);
+
+ expect(result).toHaveClass(`identicon ${DEFAULT_SIZE_CLASS} bg2`);
+ expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name)));
+ });
+ });
+
+ describe('renderAvatar', () => {
+ it('renders an image with the avatarUrl', () => {
+ const avatarUrl = `${TEST_HOST}/not-real-assets/test.png`;
+
+ const result = renderAvatar({
+ avatar_url: avatarUrl,
+ });
+
+ expect(result).toBeMatchedBy('img');
+ expect(result).toHaveAttr('src', avatarUrl);
+ expect(result).toHaveClass(DEFAULT_SIZE_CLASS);
+ });
+
+ it('renders an identicon if no avatarUrl', () => {
+ const entity = {
+ id: 1,
+ name: 'walrus',
+ };
+ const options = {
+ sizeClass: 's16',
+ };
+
+ const result = renderAvatar(entity, options);
+
+ expect(result).toHaveClass(`identicon ${options.sizeClass} bg2`);
+ expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name)));
+ });
+ });
+});
diff --git a/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js b/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js
index a98919e2113..eab805382bd 100644
--- a/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js
+++ b/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js
@@ -57,6 +57,18 @@ describe('PasteMarkdownTable', () => {
expect(new PasteMarkdownTable(data).isTable()).toBe(false);
});
+
+ it('returns false when the table copy comes from a diff', () => {
+ data.types = ['text/html', 'text/plain'];
+ data.getData = jest.fn().mockImplementation(mimeType => {
+ if (mimeType === 'text/html') {
+ return '<table class="diff-wrap-lines"><tr><td>First</td><td>Second</td></tr></table>';
+ }
+ return 'First\tSecond';
+ });
+
+ expect(new PasteMarkdownTable(data).isTable()).toBe(false);
+ });
});
describe('convertToTableMarkdown', () => {
diff --git a/spec/frontend/behaviors/markdown/render_metrics_spec.js b/spec/frontend/behaviors/markdown/render_metrics_spec.js
index 3f7beeb817b..ab81ed6b8f0 100644
--- a/spec/frontend/behaviors/markdown/render_metrics_spec.js
+++ b/spec/frontend/behaviors/markdown/render_metrics_spec.js
@@ -11,20 +11,20 @@ const getElements = () => Array.from(document.getElementsByClassName('js-render-
describe('Render metrics for Gitlab Flavoured Markdown', () => {
it('does nothing when no elements are found', () => {
- renderMetrics([]);
-
- expect(mockEmbedGroup).not.toHaveBeenCalled();
+ return renderMetrics([]).then(() => {
+ expect(mockEmbedGroup).not.toHaveBeenCalled();
+ });
});
it('renders a vue component when elements are found', () => {
document.body.innerHTML = `<div class="js-render-metrics" data-dashboard-url="${TEST_HOST}"></div>`;
- renderMetrics(getElements());
-
- expect(mockEmbedGroup).toHaveBeenCalledTimes(1);
- expect(mockEmbedGroup).toHaveBeenCalledWith(
- expect.objectContaining({ propsData: { urls: [`${TEST_HOST}`] } }),
- );
+ return renderMetrics(getElements()).then(() => {
+ expect(mockEmbedGroup).toHaveBeenCalledTimes(1);
+ expect(mockEmbedGroup).toHaveBeenCalledWith(
+ expect.objectContaining({ propsData: { urls: [`${TEST_HOST}`] } }),
+ );
+ });
});
it('takes sibling metrics and groups them under a shared parent', () => {
@@ -36,14 +36,14 @@ describe('Render metrics for Gitlab Flavoured Markdown', () => {
<div class="js-render-metrics" data-dashboard-url="${TEST_HOST}/3"></div>
`;
- renderMetrics(getElements());
-
- expect(mockEmbedGroup).toHaveBeenCalledTimes(2);
- expect(mockEmbedGroup).toHaveBeenCalledWith(
- expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/1`, `${TEST_HOST}/2`] } }),
- );
- expect(mockEmbedGroup).toHaveBeenCalledWith(
- expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/3`] } }),
- );
+ return renderMetrics(getElements()).then(() => {
+ expect(mockEmbedGroup).toHaveBeenCalledTimes(2);
+ expect(mockEmbedGroup).toHaveBeenCalledWith(
+ expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/1`, `${TEST_HOST}/2`] } }),
+ );
+ expect(mockEmbedGroup).toHaveBeenCalledWith(
+ expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/3`] } }),
+ );
+ });
});
});
diff --git a/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap
index e47a7dcfa2a..1e639f91797 100644
--- a/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap
+++ b/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap
@@ -5,7 +5,7 @@ exports[`Blob Header Editing rendering matches the snapshot 1`] = `
class="js-file-title file-title-flex-parent"
>
<gl-form-input-stub
- class="form-control js-snippet-file-name qa-snippet-file-name"
+ class="form-control js-snippet-file-name"
id="snippet_file_name"
name="snippet_file_name"
placeholder="Give your file a name to add code highlighting, e.g. example.rb for Ruby"
diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap
index 7382a3a4cf7..2ac6e0d5d24 100644
--- a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap
+++ b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap
@@ -8,14 +8,15 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = `
<file-icon-stub
aria-hidden="true"
cssclasses="mr-2"
- filename="dummy.md"
+ filename="foo/bar/dummy.md"
size="18"
/>
<strong
- class="file-title-name qa-file-title-name mr-1 js-blob-header-filepath"
+ class="file-title-name mr-1 js-blob-header-filepath"
+ data-qa-selector="file_title_name"
>
- dummy.md
+ foo/bar/dummy.md
</strong>
<small
@@ -26,8 +27,8 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = `
<clipboard-button-stub
cssclass="btn-clipboard btn-transparent lh-100 position-static"
- gfm="\`dummy.md\`"
- text="dummy.md"
+ gfm="\`foo/bar/dummy.md\`"
+ text="foo/bar/dummy.md"
title="Copy file path"
tooltipplacement="top"
/>
diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap
index 2878ad492a4..7d868625956 100644
--- a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap
+++ b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap
@@ -9,7 +9,7 @@ exports[`Blob Header Default Actions rendering matches the snapshot 1`] = `
/>
<div
- class="file-actions d-none d-sm-block"
+ class="file-actions d-none d-sm-flex"
>
<viewer-switcher-stub
value="simple"
diff --git a/spec/frontend/blob/components/blob_content_error_spec.js b/spec/frontend/blob/components/blob_content_error_spec.js
index 58a9ee761df..6eb5cfb71aa 100644
--- a/spec/frontend/blob/components/blob_content_error_spec.js
+++ b/spec/frontend/blob/components/blob_content_error_spec.js
@@ -1,27 +1,60 @@
import { shallowMount } from '@vue/test-utils';
import BlobContentError from '~/blob/components/blob_content_error.vue';
+import { GlSprintf } from '@gitlab/ui';
+
+import { BLOB_RENDER_ERRORS } from '~/blob/components/constants';
describe('Blob Content Error component', () => {
let wrapper;
- const viewerError = '<h1 id="error">Foo Error</h1>';
- function createComponent() {
+ function createComponent(props = {}) {
wrapper = shallowMount(BlobContentError, {
propsData: {
- viewerError,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
},
});
}
- beforeEach(() => {
- createComponent();
- });
-
afterEach(() => {
wrapper.destroy();
});
- it('renders the passed error without transformations', () => {
- expect(wrapper.html()).toContain(viewerError);
+ describe('collapsed and too large blobs', () => {
+ it.each`
+ error | reason | options
+ ${BLOB_RENDER_ERRORS.REASONS.COLLAPSED} | ${'it is larger than 1.00 MiB'} | ${[BLOB_RENDER_ERRORS.OPTIONS.LOAD.text, BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
+ ${BLOB_RENDER_ERRORS.REASONS.TOO_LARGE} | ${'it is larger than 100.00 MiB'} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
+ `('renders correct reason for $error.id', ({ error, reason, options }) => {
+ createComponent({
+ viewerError: error.id,
+ });
+ expect(wrapper.text()).toContain(reason);
+ options.forEach(option => {
+ expect(wrapper.text()).toContain(option);
+ });
+ });
+ });
+
+ describe('external blob', () => {
+ it.each`
+ storageType | reason | options
+ ${'lfs'} | ${BLOB_RENDER_ERRORS.REASONS.EXTERNAL.text.lfs} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
+ ${'build_artifact'} | ${BLOB_RENDER_ERRORS.REASONS.EXTERNAL.text.build_artifact} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
+ ${'default'} | ${BLOB_RENDER_ERRORS.REASONS.EXTERNAL.text.default} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
+ `('renders correct reason for $storageType blob', ({ storageType, reason, options }) => {
+ createComponent({
+ viewerError: BLOB_RENDER_ERRORS.REASONS.EXTERNAL.id,
+ blob: {
+ externalStorage: storageType,
+ },
+ });
+ expect(wrapper.text()).toContain(reason);
+ options.forEach(option => {
+ expect(wrapper.text()).toContain(option);
+ });
+ });
});
});
diff --git a/spec/frontend/blob/components/blob_content_spec.js b/spec/frontend/blob/components/blob_content_spec.js
index 6a130c9c43d..244ed41869d 100644
--- a/spec/frontend/blob/components/blob_content_spec.js
+++ b/spec/frontend/blob/components/blob_content_spec.js
@@ -2,6 +2,12 @@ import { shallowMount } from '@vue/test-utils';
import BlobContent from '~/blob/components/blob_content.vue';
import BlobContentError from '~/blob/components/blob_content_error.vue';
import {
+ BLOB_RENDER_EVENT_LOAD,
+ BLOB_RENDER_EVENT_SHOW_SOURCE,
+ BLOB_RENDER_ERRORS,
+} from '~/blob/components/constants';
+import {
+ Blob,
RichViewerMock,
SimpleViewerMock,
RichBlobContentMock,
@@ -38,7 +44,7 @@ describe('Blob Content component', () => {
it('renders error if there is any in the viewer', () => {
const renderError = 'Oops';
- const viewer = Object.assign({}, SimpleViewerMock, { renderError });
+ const viewer = { ...SimpleViewerMock, renderError };
createComponent({}, viewer);
expect(wrapper.contains(GlLoadingIcon)).toBe(false);
expect(wrapper.contains(BlobContentError)).toBe(true);
@@ -67,4 +73,32 @@ describe('Blob Content component', () => {
expect(wrapper.find(viewer).html()).toContain(content);
});
});
+
+ describe('functionality', () => {
+ describe('render error', () => {
+ const findErrorEl = () => wrapper.find(BlobContentError);
+ const renderError = BLOB_RENDER_ERRORS.REASONS.COLLAPSED.id;
+ const viewer = { ...SimpleViewerMock, renderError };
+
+ beforeEach(() => {
+ createComponent({ blob: Blob }, viewer);
+ });
+
+ it('correctly sets blob on the blob-content-error component', () => {
+ expect(findErrorEl().props('blob')).toEqual(Blob);
+ });
+
+ it(`properly proxies ${BLOB_RENDER_EVENT_LOAD} event`, () => {
+ expect(wrapper.emitted(BLOB_RENDER_EVENT_LOAD)).toBeUndefined();
+ findErrorEl().vm.$emit(BLOB_RENDER_EVENT_LOAD);
+ expect(wrapper.emitted(BLOB_RENDER_EVENT_LOAD)).toBeTruthy();
+ });
+
+ it(`properly proxies ${BLOB_RENDER_EVENT_SHOW_SOURCE} event`, () => {
+ expect(wrapper.emitted(BLOB_RENDER_EVENT_SHOW_SOURCE)).toBeUndefined();
+ findErrorEl().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE);
+ expect(wrapper.emitted(BLOB_RENDER_EVENT_SHOW_SOURCE)).toBeTruthy();
+ });
+ });
+ });
});
diff --git a/spec/frontend/blob/components/blob_header_filepath_spec.js b/spec/frontend/blob/components/blob_header_filepath_spec.js
index d029ba2a7a4..3a53208f357 100644
--- a/spec/frontend/blob/components/blob_header_filepath_spec.js
+++ b/spec/frontend/blob/components/blob_header_filepath_spec.js
@@ -15,7 +15,7 @@ describe('Blob Header Filepath', () => {
function createComponent(blobProps = {}, options = {}) {
wrapper = shallowMount(BlobHeaderFilepath, {
propsData: {
- blob: Object.assign({}, MockBlob, blobProps),
+ blob: { ...MockBlob, ...blobProps },
},
...options,
});
@@ -38,12 +38,12 @@ describe('Blob Header Filepath', () => {
.find('.js-blob-header-filepath')
.text()
.trim(),
- ).toBe(MockBlob.name);
+ ).toBe(MockBlob.path);
});
it('does not fail if the name is empty', () => {
- const emptyName = '';
- createComponent({ name: emptyName });
+ const emptyPath = '';
+ createComponent({ path: emptyPath });
expect(wrapper.find('.js-blob-header-filepath').exists()).toBe(false);
});
@@ -84,7 +84,7 @@ describe('Blob Header Filepath', () => {
describe('functionality', () => {
it('sets gfm value correctly on the clipboard-button', () => {
createComponent();
- expect(wrapper.vm.gfmCopyText).toBe('`dummy.md`');
+ expect(wrapper.vm.gfmCopyText).toBe(`\`${MockBlob.path}\``);
});
});
});
diff --git a/spec/frontend/blob/components/blob_header_spec.js b/spec/frontend/blob/components/blob_header_spec.js
index d410ef10fc9..0e7d2f6516a 100644
--- a/spec/frontend/blob/components/blob_header_spec.js
+++ b/spec/frontend/blob/components/blob_header_spec.js
@@ -13,7 +13,7 @@ describe('Blob Header Default Actions', () => {
const method = shouldMount ? mount : shallowMount;
wrapper = method.call(this, BlobHeader, {
propsData: {
- blob: Object.assign({}, Blob, blobProps),
+ blob: { ...Blob, ...blobProps },
...propsData,
},
...options,
diff --git a/spec/frontend/blob/components/mock_data.js b/spec/frontend/blob/components/mock_data.js
index bfcca14324f..0f7193846ff 100644
--- a/spec/frontend/blob/components/mock_data.js
+++ b/spec/frontend/blob/components/mock_data.js
@@ -21,7 +21,7 @@ export const RichViewerMock = {
export const Blob = {
binary: false,
name: 'dummy.md',
- path: 'dummy.md',
+ path: 'foo/bar/dummy.md',
rawPath: '/flightjs/flight/snippets/51/raw',
size: 75,
simpleViewer: {
diff --git a/spec/frontend/blob/pipeline_tour_success_modal_spec.js b/spec/frontend/blob/pipeline_tour_success_modal_spec.js
index 99940225652..6d4e5e46cb8 100644
--- a/spec/frontend/blob/pipeline_tour_success_modal_spec.js
+++ b/spec/frontend/blob/pipeline_tour_success_modal_spec.js
@@ -12,8 +12,8 @@ describe('PipelineTourSuccessModal', () => {
beforeEach(() => {
document.body.dataset.page = 'projects:blob:show';
-
trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
+
wrapper = shallowMount(pipelineTourSuccess, {
propsData: modalProps,
stubs: {
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 fb0964a3f32..3c03e6f04ab 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
@@ -69,8 +69,10 @@ describe('Suggest gitlab-ci.yml Popover', () => {
let trackingSpy;
beforeEach(() => {
+ document.body.dataset.page = 'projects:blob:new';
+ trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
+
createWrapper(commitTrackLabel);
- trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
});
afterEach(() => {
@@ -83,10 +85,6 @@ describe('Suggest gitlab-ci.yml Popover', () => {
const expectedLabel = 'suggest_commit_first_project_gitlab_ci_yml';
const expectedProperty = 'owner';
- document.body.dataset.page = 'projects:blob:new';
-
- wrapper.vm.trackOnShow();
-
expect(trackingSpy).toHaveBeenCalledWith(expectedCategory, expectedAction, {
label: expectedLabel,
property: expectedProperty,
@@ -99,6 +97,7 @@ describe('Suggest gitlab-ci.yml Popover', () => {
const expectedProperty = 'owner';
const expectedValue = '10';
const dismissButton = wrapper.find(GlDeprecatedButton);
+ trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
triggerEvent(dismissButton.element);
diff --git a/spec/frontend/blob/utils_spec.js b/spec/frontend/blob/utils_spec.js
index 39a73aae444..119ed2dfe7a 100644
--- a/spec/frontend/blob/utils_spec.js
+++ b/spec/frontend/blob/utils_spec.js
@@ -8,11 +8,6 @@ jest.mock('~/editor/editor_lite', () => {
});
});
-const mockCreateAceInstance = jest.fn();
-global.ace = {
- edit: mockCreateAceInstance,
-};
-
describe('Blob utilities', () => {
beforeEach(() => {
Editor.mockClear();
@@ -29,21 +24,6 @@ describe('Blob utilities', () => {
});
describe('Monaco editor', () => {
- let origProp;
-
- beforeEach(() => {
- origProp = window.gon;
- window.gon = {
- features: {
- monacoSnippets: true,
- },
- };
- });
-
- afterEach(() => {
- window.gon = origProp;
- });
-
it('initializes the Editor Lite', () => {
utils.initEditorLite({ el: editorEl });
expect(Editor).toHaveBeenCalled();
@@ -69,27 +49,5 @@ describe('Blob utilities', () => {
]);
});
});
- describe('ACE editor', () => {
- let origProp;
-
- beforeEach(() => {
- origProp = window.gon;
- window.gon = {
- features: {
- monacoSnippets: false,
- },
- };
- });
-
- afterEach(() => {
- window.gon = origProp;
- });
-
- it('does not initialize the Editor Lite', () => {
- utils.initEditorLite({ el: editorEl });
- expect(Editor).not.toHaveBeenCalled();
- expect(mockCreateAceInstance).toHaveBeenCalledWith(editorEl);
- });
- });
});
});
diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js
index 882310030f8..fa21053e2de 100644
--- a/spec/frontend/boards/board_list_spec.js
+++ b/spec/frontend/boards/board_list_spec.js
@@ -64,7 +64,7 @@ describe('Board list component', () => {
let getIssues;
function generateIssues(compWrapper) {
for (let i = 1; i < 20; i += 1) {
- const issue = Object.assign({}, compWrapper.list.issues[0]);
+ const issue = { ...compWrapper.list.issues[0] };
issue.id += i;
compWrapper.list.issues.push(issue);
}
diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js
index 5c5315fd465..29cc8f981bd 100644
--- a/spec/frontend/boards/boards_store_spec.js
+++ b/spec/frontend/boards/boards_store_spec.js
@@ -214,6 +214,22 @@ describe('boardsStore', () => {
});
});
+ describe('getListIssues', () => {
+ let list;
+
+ beforeEach(() => {
+ list = new List(listObj);
+ setupDefaultResponses();
+ });
+
+ it('makes a request to get issues', () => {
+ const expectedResponse = expect.objectContaining({ issues: [createTestIssue()] });
+ expect(list.issues).toEqual([]);
+
+ return expect(boardsStore.getListIssues(list, true)).resolves.toEqual(expectedResponse);
+ });
+ });
+
describe('getIssuesForList', () => {
const id = 'TOO-MUCH';
const url = `${endpoints.listsEndpoint}/${id}/issues?id=${id}`;
@@ -1040,5 +1056,126 @@ describe('boardsStore', () => {
});
});
});
+
+ describe('addListIssue', () => {
+ let list;
+ const issue1 = new ListIssue({
+ title: 'Testing',
+ id: 2,
+ iid: 2,
+ confidential: false,
+ labels: [
+ {
+ color: '#ff0000',
+ description: 'testing;',
+ id: 5000,
+ priority: undefined,
+ textColor: 'white',
+ title: 'Test',
+ },
+ ],
+ assignees: [],
+ });
+ const issue2 = new ListIssue({
+ title: 'Testing',
+ id: 1,
+ iid: 1,
+ confidential: false,
+ labels: [
+ {
+ id: 1,
+ title: 'test',
+ color: 'red',
+ description: 'testing',
+ },
+ ],
+ assignees: [
+ {
+ id: 1,
+ name: 'name',
+ username: 'username',
+ avatar_url: 'http://avatar_url',
+ },
+ ],
+ real_path: 'path/to/issue',
+ });
+
+ beforeEach(() => {
+ list = new List(listObj);
+ list.addIssue(issue1);
+ setupDefaultResponses();
+ });
+
+ it('adds issues that are not already on the list', () => {
+ expect(list.findIssue(issue2.id)).toBe(undefined);
+ expect(list.issues).toEqual([issue1]);
+
+ boardsStore.addListIssue(list, issue2);
+ expect(list.findIssue(issue2.id)).toBe(issue2);
+ expect(list.issues.length).toBe(2);
+ expect(list.issues).toEqual([issue1, issue2]);
+ });
+ });
+
+ describe('updateIssue', () => {
+ let issue;
+ let patchSpy;
+
+ beforeEach(() => {
+ issue = new ListIssue({
+ title: 'Testing',
+ id: 1,
+ iid: 1,
+ confidential: false,
+ labels: [
+ {
+ id: 1,
+ title: 'test',
+ color: 'red',
+ description: 'testing',
+ },
+ ],
+ assignees: [
+ {
+ id: 1,
+ name: 'name',
+ username: 'username',
+ avatar_url: 'http://avatar_url',
+ },
+ ],
+ real_path: 'path/to/issue',
+ });
+
+ patchSpy = jest.fn().mockReturnValue([200, { labels: [] }]);
+ axiosMock.onPatch(`path/to/issue.json`).reply(({ data }) => patchSpy(JSON.parse(data)));
+ });
+
+ it('passes assignee ids when there are assignees', () => {
+ boardsStore.updateIssue(issue);
+ return boardsStore.updateIssue(issue).then(() => {
+ expect(patchSpy).toHaveBeenCalledWith({
+ issue: {
+ milestone_id: null,
+ assignee_ids: [1],
+ label_ids: [1],
+ },
+ });
+ });
+ });
+
+ it('passes assignee ids of [0] when there are no assignees', () => {
+ issue.removeAllAssignees();
+
+ return boardsStore.updateIssue(issue).then(() => {
+ expect(patchSpy).toHaveBeenCalledWith({
+ issue: {
+ milestone_id: null,
+ assignee_ids: [0],
+ label_ids: [1],
+ },
+ });
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/boards/issue_spec.js b/spec/frontend/boards/issue_spec.js
index ff72edaa695..412f20684f5 100644
--- a/spec/frontend/boards/issue_spec.js
+++ b/spec/frontend/boards/issue_spec.js
@@ -1,6 +1,5 @@
/* global ListIssue */
-import axios from '~/lib/utils/axios_utils';
import '~/boards/models/label';
import '~/boards/models/assignee';
import '~/boards/models/issue';
@@ -173,25 +172,12 @@ describe('Issue model', () => {
});
describe('update', () => {
- it('passes assignee ids when there are assignees', done => {
- jest.spyOn(axios, 'patch').mockImplementation((url, data) => {
- expect(data.issue.assignee_ids).toEqual([1]);
- done();
- return Promise.resolve();
- });
-
- issue.update('url');
- });
+ it('passes update to boardsStore', () => {
+ jest.spyOn(boardsStore, 'updateIssue').mockImplementation();
- it('passes assignee ids of [0] when there are no assignees', done => {
- jest.spyOn(axios, 'patch').mockImplementation((url, data) => {
- expect(data.issue.assignee_ids).toEqual([0]);
- done();
- return Promise.resolve();
- });
+ issue.update();
- issue.removeAllAssignees();
- issue.update('url');
+ expect(boardsStore.updateIssue).toHaveBeenCalledWith(issue);
});
});
});
diff --git a/spec/frontend/bootstrap_linked_tabs_spec.js b/spec/frontend/bootstrap_linked_tabs_spec.js
new file mode 100644
index 00000000000..2d8939e6480
--- /dev/null
+++ b/spec/frontend/bootstrap_linked_tabs_spec.js
@@ -0,0 +1,67 @@
+import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
+
+describe('Linked Tabs', () => {
+ preloadFixtures('static/linked_tabs.html');
+
+ beforeEach(() => {
+ loadFixtures('static/linked_tabs.html');
+ });
+
+ describe('when is initialized', () => {
+ beforeEach(() => {
+ jest.spyOn(window.history, 'replaceState').mockImplementation(() => {});
+ });
+
+ it('should activate the tab correspondent to the given action', () => {
+ // eslint-disable-next-line no-new
+ new LinkedTabs({
+ action: 'tab1',
+ defaultAction: 'tab1',
+ parentEl: '.linked-tabs',
+ });
+
+ expect(document.querySelector('#tab1').classList).toContain('active');
+ });
+
+ it('should active the default tab action when the action is show', () => {
+ // eslint-disable-next-line no-new
+ new LinkedTabs({
+ action: 'show',
+ defaultAction: 'tab1',
+ parentEl: '.linked-tabs',
+ });
+
+ expect(document.querySelector('#tab1').classList).toContain('active');
+ });
+ });
+
+ describe('on click', () => {
+ it('should change the url according to the clicked tab', () => {
+ const historySpy = jest.spyOn(window.history, 'replaceState').mockImplementation(() => {});
+
+ const linkedTabs = new LinkedTabs({
+ action: 'show',
+ defaultAction: 'tab1',
+ parentEl: '.linked-tabs',
+ });
+
+ const secondTab = document.querySelector('.linked-tabs li:nth-child(2) a');
+ const newState =
+ secondTab.getAttribute('href') +
+ linkedTabs.currentLocation.search +
+ linkedTabs.currentLocation.hash;
+
+ secondTab.click();
+
+ if (historySpy) {
+ expect(historySpy).toHaveBeenCalledWith(
+ {
+ url: newState,
+ },
+ document.title,
+ newState,
+ );
+ }
+ });
+ });
+});
diff --git a/spec/frontend/broadcast_notification_spec.js b/spec/frontend/broadcast_notification_spec.js
new file mode 100644
index 00000000000..8d433946632
--- /dev/null
+++ b/spec/frontend/broadcast_notification_spec.js
@@ -0,0 +1,35 @@
+import Cookies from 'js-cookie';
+import initBroadcastNotifications from '~/broadcast_notification';
+
+describe('broadcast message on dismiss', () => {
+ const dismiss = () => {
+ const button = document.querySelector('.js-dismiss-current-broadcast-notification');
+ button.click();
+ };
+ const endsAt = '2020-01-01T00:00:00Z';
+
+ beforeEach(() => {
+ setFixtures(`
+ <div class="js-broadcast-notification-1">
+ <button class="js-dismiss-current-broadcast-notification" data-id="1" data-expire-date="${endsAt}"></button>
+ </div>
+ `);
+
+ initBroadcastNotifications();
+ });
+
+ it('removes broadcast message', () => {
+ dismiss();
+
+ expect(document.querySelector('.js-broadcast-notification-1')).toBeNull();
+ });
+
+ it('calls Cookies.set', () => {
+ jest.spyOn(Cookies, 'set');
+ dismiss();
+
+ expect(Cookies.set).toHaveBeenCalledWith('hide_broadcast_message_1', true, {
+ expires: new Date(endsAt),
+ });
+ });
+});
diff --git a/spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js
new file mode 100644
index 00000000000..93b185bd242
--- /dev/null
+++ b/spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js
@@ -0,0 +1,203 @@
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import AjaxFormVariableList from '~/ci_variable_list/ajax_variable_list';
+
+const VARIABLE_PATCH_ENDPOINT = 'http://test.host/frontend-fixtures/builds-project/-/variables';
+const HIDE_CLASS = 'hide';
+
+describe('AjaxFormVariableList', () => {
+ preloadFixtures('projects/ci_cd_settings.html');
+ preloadFixtures('projects/ci_cd_settings_with_variables.html');
+
+ let container;
+ let saveButton;
+ let errorBox;
+
+ let mock;
+ let ajaxVariableList;
+
+ beforeEach(() => {
+ loadFixtures('projects/ci_cd_settings.html');
+ container = document.querySelector('.js-ci-variable-list-section');
+
+ mock = new MockAdapter(axios);
+
+ const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section');
+ saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button');
+ errorBox = container.querySelector('.js-ci-variable-error-box');
+ ajaxVariableList = new AjaxFormVariableList({
+ container,
+ formField: 'variables',
+ saveButton,
+ errorBox,
+ saveEndpoint: container.dataset.saveEndpoint,
+ maskableRegex: container.dataset.maskableRegex,
+ });
+
+ jest.spyOn(ajaxVariableList, 'updateRowsWithPersistedVariables');
+ jest.spyOn(ajaxVariableList.variableList, 'toggleEnableRow');
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('onSaveClicked', () => {
+ it('shows loading spinner while waiting for the request', () => {
+ const loadingIcon = saveButton.querySelector('.js-ci-variables-save-loading-icon');
+
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => {
+ expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(false);
+
+ return [200, {}];
+ });
+
+ expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true);
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true);
+ });
+ });
+
+ it('calls `updateRowsWithPersistedVariables` with the persisted variables', () => {
+ const variablesResponse = [{ id: 1, key: 'foo', value: 'bar' }];
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {
+ variables: variablesResponse,
+ });
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(ajaxVariableList.updateRowsWithPersistedVariables).toHaveBeenCalledWith(
+ variablesResponse,
+ );
+ });
+ });
+
+ it('hides any previous error box', () => {
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200);
+
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+ });
+ });
+
+ it('disables remove buttons while waiting for the request', () => {
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => {
+ expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(false);
+
+ return [200, {}];
+ });
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(true);
+ });
+ });
+
+ it('hides secret values', () => {
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {});
+
+ const row = container.querySelector('.js-row');
+ const valueInput = row.querySelector('.js-ci-variable-input-value');
+ const valuePlaceholder = row.querySelector('.js-secret-value-placeholder');
+
+ valueInput.value = 'bar';
+ $(valueInput).trigger('input');
+
+ expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(true);
+ expect(valueInput.classList.contains(HIDE_CLASS)).toBe(false);
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(false);
+ expect(valueInput.classList.contains(HIDE_CLASS)).toBe(true);
+ });
+ });
+
+ it('shows error box with validation errors', () => {
+ const validationError = 'some validation error';
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(400, [validationError]);
+
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(false);
+ expect(errorBox.textContent.trim().replace(/\n+\s+/m, ' ')).toEqual(
+ `Validation failed ${validationError}`,
+ );
+ });
+ });
+
+ it('shows flash message when request fails', () => {
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(500);
+
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+ });
+ });
+ });
+
+ describe('updateRowsWithPersistedVariables', () => {
+ beforeEach(() => {
+ loadFixtures('projects/ci_cd_settings_with_variables.html');
+ container = document.querySelector('.js-ci-variable-list-section');
+
+ const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section');
+ saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button');
+ errorBox = container.querySelector('.js-ci-variable-error-box');
+ ajaxVariableList = new AjaxFormVariableList({
+ container,
+ formField: 'variables',
+ saveButton,
+ errorBox,
+ saveEndpoint: container.dataset.saveEndpoint,
+ });
+ });
+
+ it('removes variable that was removed', () => {
+ expect(container.querySelectorAll('.js-row').length).toBe(3);
+
+ container.querySelector('.js-row-remove-button').click();
+
+ expect(container.querySelectorAll('.js-row').length).toBe(3);
+
+ ajaxVariableList.updateRowsWithPersistedVariables([]);
+
+ expect(container.querySelectorAll('.js-row').length).toBe(2);
+ });
+
+ it('updates new variable row with persisted ID', () => {
+ const row = container.querySelector('.js-row:last-child');
+ const idInput = row.querySelector('.js-ci-variable-input-id');
+ const keyInput = row.querySelector('.js-ci-variable-input-key');
+ const valueInput = row.querySelector('.js-ci-variable-input-value');
+
+ keyInput.value = 'foo';
+ $(keyInput).trigger('input');
+ valueInput.value = 'bar';
+ $(valueInput).trigger('input');
+
+ expect(idInput.value).toEqual('');
+
+ ajaxVariableList.updateRowsWithPersistedVariables([
+ {
+ id: 3,
+ key: 'foo',
+ value: 'bar',
+ },
+ ]);
+
+ expect(idInput.value).toEqual('3');
+ expect(row.dataset.isPersisted).toEqual('true');
+ });
+ });
+
+ describe('maskableRegex', () => {
+ it('takes in the regex provided by the data attribute', () => {
+ expect(container.dataset.maskableRegex).toBe('^[a-zA-Z0-9_+=/@:.-]{8,}$');
+ expect(ajaxVariableList.maskableRegex).toBe(container.dataset.maskableRegex);
+ });
+ });
+});
diff --git a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
new file mode 100644
index 00000000000..9508203e5c2
--- /dev/null
+++ b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
@@ -0,0 +1,282 @@
+import $ from 'jquery';
+import waitForPromises from 'helpers/wait_for_promises';
+import VariableList from '~/ci_variable_list/ci_variable_list';
+
+const HIDE_CLASS = 'hide';
+
+describe('VariableList', () => {
+ preloadFixtures('pipeline_schedules/edit.html');
+ preloadFixtures('pipeline_schedules/edit_with_variables.html');
+ preloadFixtures('projects/ci_cd_settings.html');
+
+ let $wrapper;
+ let variableList;
+
+ describe('with only key/value inputs', () => {
+ describe('with no variables', () => {
+ beforeEach(() => {
+ loadFixtures('pipeline_schedules/edit.html');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'schedule',
+ });
+ variableList.init();
+ });
+
+ it('should remove the row when clicking the remove button', () => {
+ $wrapper.find('.js-row-remove-button').trigger('click');
+
+ expect($wrapper.find('.js-row').length).toBe(0);
+ });
+
+ it('should add another row when editing the last rows key input', () => {
+ const $row = $wrapper.find('.js-row');
+ $row
+ .find('.js-ci-variable-input-key')
+ .val('foo')
+ .trigger('input');
+
+ expect($wrapper.find('.js-row').length).toBe(2);
+
+ // Check for the correct default in the new row
+ const $keyInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key');
+
+ expect($keyInput.val()).toBe('');
+ });
+
+ it('should add another row when editing the last rows value textarea', () => {
+ const $row = $wrapper.find('.js-row');
+ $row
+ .find('.js-ci-variable-input-value')
+ .val('foo')
+ .trigger('input');
+
+ expect($wrapper.find('.js-row').length).toBe(2);
+
+ // Check for the correct default in the new row
+ const $valueInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key');
+
+ expect($valueInput.val()).toBe('');
+ });
+
+ it('should remove empty row after blurring', () => {
+ const $row = $wrapper.find('.js-row');
+ $row
+ .find('.js-ci-variable-input-key')
+ .val('foo')
+ .trigger('input');
+
+ expect($wrapper.find('.js-row').length).toBe(2);
+
+ $row
+ .find('.js-ci-variable-input-key')
+ .val('')
+ .trigger('input')
+ .trigger('blur');
+
+ expect($wrapper.find('.js-row').length).toBe(1);
+ });
+ });
+
+ describe('with persisted variables', () => {
+ beforeEach(() => {
+ loadFixtures('pipeline_schedules/edit_with_variables.html');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'schedule',
+ });
+ variableList.init();
+ });
+
+ it('should have "Reveal values" button initially when there are already variables', () => {
+ expect($wrapper.find('.js-secret-value-reveal-button').text()).toBe('Reveal values');
+ });
+
+ it('should reveal hidden values', () => {
+ const $row = $wrapper.find('.js-row:first-child');
+ const $inputValue = $row.find('.js-ci-variable-input-value');
+ const $placeholder = $row.find('.js-secret-value-placeholder');
+
+ expect($placeholder.hasClass(HIDE_CLASS)).toBe(false);
+ expect($inputValue.hasClass(HIDE_CLASS)).toBe(true);
+
+ // Reveal values
+ $wrapper.find('.js-secret-value-reveal-button').click();
+
+ expect($placeholder.hasClass(HIDE_CLASS)).toBe(true);
+ expect($inputValue.hasClass(HIDE_CLASS)).toBe(false);
+ });
+ });
+ });
+
+ describe('with all inputs(key, value, protected)', () => {
+ beforeEach(() => {
+ loadFixtures('projects/ci_cd_settings.html');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ $wrapper.find('.js-ci-variable-input-protected').attr('data-default', 'false');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'variables',
+ });
+ variableList.init();
+ });
+
+ it('should not add another row when editing the last rows protected checkbox', () => {
+ const $row = $wrapper.find('.js-row:last-child');
+ $row.find('.ci-variable-protected-item .js-project-feature-toggle').click();
+
+ return waitForPromises().then(() => {
+ expect($wrapper.find('.js-row').length).toBe(1);
+ });
+ });
+
+ it('should not add another row when editing the last rows masked checkbox', () => {
+ jest.spyOn(variableList, 'checkIfRowTouched');
+ const $row = $wrapper.find('.js-row:last-child');
+ $row.find('.ci-variable-masked-item .js-project-feature-toggle').click();
+
+ return waitForPromises().then(() => {
+ // This validates that we are checking after the event listener has run
+ expect(variableList.checkIfRowTouched).toHaveBeenCalled();
+ expect($wrapper.find('.js-row').length).toBe(1);
+ });
+ });
+
+ describe('validateMaskability', () => {
+ let $row;
+
+ const maskingErrorElement = '.js-row:last-child .masking-validation-error';
+ const clickToggle = () =>
+ $row.find('.ci-variable-masked-item .js-project-feature-toggle').click();
+
+ beforeEach(() => {
+ $row = $wrapper.find('.js-row:last-child');
+ });
+
+ it('has a regex provided via a data attribute', () => {
+ clickToggle();
+
+ expect($wrapper.attr('data-maskable-regex')).toBe('^[a-zA-Z0-9_+=/@:.-]{8,}$');
+ });
+
+ it('allows values that are 8 characters long', () => {
+ $row.find('.js-ci-variable-input-value').val('looooong');
+
+ clickToggle();
+
+ expect($wrapper.find(maskingErrorElement)).toHaveClass('hide');
+ });
+
+ it('rejects values that are shorter than 8 characters', () => {
+ $row.find('.js-ci-variable-input-value').val('short');
+
+ clickToggle();
+
+ expect($wrapper.find(maskingErrorElement)).toBeVisible();
+ });
+
+ it('allows values with base 64 characters', () => {
+ $row.find('.js-ci-variable-input-value').val('abcABC123_+=/-');
+
+ clickToggle();
+
+ expect($wrapper.find(maskingErrorElement)).toHaveClass('hide');
+ });
+
+ it('rejects values with other special characters', () => {
+ $row.find('.js-ci-variable-input-value').val('1234567$');
+
+ clickToggle();
+
+ expect($wrapper.find(maskingErrorElement)).toBeVisible();
+ });
+ });
+ });
+
+ describe('toggleEnableRow method', () => {
+ beforeEach(() => {
+ loadFixtures('pipeline_schedules/edit_with_variables.html');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'variables',
+ });
+ variableList.init();
+ });
+
+ it('should disable all key inputs', () => {
+ expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3);
+
+ variableList.toggleEnableRow(false);
+
+ expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3);
+ });
+
+ it('should disable all remove buttons', () => {
+ expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3);
+
+ variableList.toggleEnableRow(false);
+
+ expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3);
+ });
+
+ it('should enable all remove buttons', () => {
+ variableList.toggleEnableRow(false);
+
+ expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3);
+
+ variableList.toggleEnableRow(true);
+
+ expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3);
+ });
+
+ it('should enable all key inputs', () => {
+ variableList.toggleEnableRow(false);
+
+ expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3);
+
+ variableList.toggleEnableRow(true);
+
+ expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3);
+ });
+ });
+
+ describe('hideValues', () => {
+ beforeEach(() => {
+ loadFixtures('projects/ci_cd_settings.html');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'variables',
+ });
+ variableList.init();
+ });
+
+ it('should hide value input and show placeholder stars', () => {
+ const $row = $wrapper.find('.js-row');
+ const $inputValue = $row.find('.js-ci-variable-input-value');
+ const $placeholder = $row.find('.js-secret-value-placeholder');
+
+ $row
+ .find('.js-ci-variable-input-value')
+ .val('foo')
+ .trigger('input');
+
+ expect($placeholder.hasClass(HIDE_CLASS)).toBe(true);
+ expect($inputValue.hasClass(HIDE_CLASS)).toBe(false);
+
+ variableList.hideValues();
+
+ expect($placeholder.hasClass(HIDE_CLASS)).toBe(false);
+ expect($inputValue.hasClass(HIDE_CLASS)).toBe(true);
+ });
+ });
+});
diff --git a/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js
index 4982b68fa81..4982b68fa81 100644
--- a/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js
+++ b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js
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 7b8d69df35e..9179302f786 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
@@ -96,6 +96,13 @@ describe('Ci variable modal', () => {
findModal().vm.$emit('hidden');
expect(store.dispatch).toHaveBeenCalledWith('clearModal');
});
+
+ it('should dispatch setVariableProtected when admin settings are configured to protect variables', () => {
+ store.state.isProtectedByDefault = true;
+ findModal().vm.$emit('shown');
+
+ expect(store.dispatch).toHaveBeenCalledWith('setVariableProtected');
+ });
});
describe('Editing a variable', () => {
diff --git a/spec/frontend/ci_variable_list/services/mock_data.js b/spec/frontend/ci_variable_list/services/mock_data.js
index 09c6cd9de21..7dab33050d9 100644
--- a/spec/frontend/ci_variable_list/services/mock_data.js
+++ b/spec/frontend/ci_variable_list/services/mock_data.js
@@ -8,7 +8,7 @@ export default {
protected: false,
secret_value: 'test_val',
value: 'test_val',
- variable_type: 'Var',
+ variable_type: 'Variable',
},
],
@@ -44,7 +44,7 @@ export default {
protected: false,
secret_value: 'test_val',
value: 'test_val',
- variable_type: 'Var',
+ variable_type: 'Variable',
},
{
environment_scope: 'All (default)',
@@ -104,7 +104,7 @@ export default {
id: 28,
key: 'goku_var',
value: 'goku_val',
- variable_type: 'Var',
+ variable_type: 'Variable',
protected: true,
masked: true,
environment_scope: 'staging',
@@ -114,7 +114,7 @@ export default {
id: 25,
key: 'test_var_4',
value: 'test_val_4',
- variable_type: 'Var',
+ variable_type: 'Variable',
protected: false,
masked: false,
environment_scope: 'production',
@@ -134,7 +134,7 @@ export default {
id: 24,
key: 'test_var_3',
value: 'test_val_3',
- variable_type: 'Var',
+ variable_type: 'Variable',
protected: false,
masked: false,
environment_scope: 'All (default)',
@@ -144,7 +144,7 @@ export default {
id: 26,
key: 'test_var_5',
value: 'test_val_5',
- variable_type: 'Var',
+ variable_type: 'Variable',
protected: false,
masked: false,
environment_scope: 'production',
diff --git a/spec/frontend/ci_variable_list/store/actions_spec.js b/spec/frontend/ci_variable_list/store/actions_spec.js
index 84455612f0c..12b4311d0f5 100644
--- a/spec/frontend/ci_variable_list/store/actions_spec.js
+++ b/spec/frontend/ci_variable_list/store/actions_spec.js
@@ -75,6 +75,16 @@ describe('CI variable list store actions', () => {
});
});
+ describe('setVariableProtected', () => {
+ it('commits SET_VARIABLE_PROTECTED mutation', () => {
+ testAction(actions.setVariableProtected, {}, {}, [
+ {
+ type: types.SET_VARIABLE_PROTECTED,
+ },
+ ]);
+ });
+ });
+
describe('deleteVariable', () => {
it('dispatch correct actions on successful deleted variable', done => {
mock.onPatch(state.endpoint).reply(200);
diff --git a/spec/frontend/ci_variable_list/store/mutations_spec.js b/spec/frontend/ci_variable_list/store/mutations_spec.js
index 8652359f3df..1934d108957 100644
--- a/spec/frontend/ci_variable_list/store/mutations_spec.js
+++ b/spec/frontend/ci_variable_list/store/mutations_spec.js
@@ -47,7 +47,7 @@ describe('CI variable list mutations', () => {
describe('CLEAR_MODAL', () => {
it('should clear modal state ', () => {
const modalState = {
- variable_type: 'Var',
+ variable_type: 'Variable',
key: '',
secret_value: '',
protected: false,
@@ -97,4 +97,12 @@ describe('CI variable list mutations', () => {
expect(stateCopy.environments).toEqual(['dev', 'production', 'staging']);
});
});
+
+ describe('SET_VARIABLE_PROTECTED', () => {
+ it('should set protected value to true', () => {
+ mutations[types.SET_VARIABLE_PROTECTED](stateCopy);
+
+ expect(stateCopy.variable.protected).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/close_reopen_report_toggle_spec.js b/spec/frontend/close_reopen_report_toggle_spec.js
new file mode 100644
index 00000000000..f6b5e4bed87
--- /dev/null
+++ b/spec/frontend/close_reopen_report_toggle_spec.js
@@ -0,0 +1,288 @@
+import CloseReopenReportToggle from '~/close_reopen_report_toggle';
+import DropLab from '~/droplab/drop_lab';
+
+describe('CloseReopenReportToggle', () => {
+ describe('class constructor', () => {
+ const dropdownTrigger = {};
+ const dropdownList = {};
+ const button = {};
+ let commentTypeToggle;
+
+ beforeEach(() => {
+ commentTypeToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+ });
+
+ it('sets .dropdownTrigger', () => {
+ expect(commentTypeToggle.dropdownTrigger).toBe(dropdownTrigger);
+ });
+
+ it('sets .dropdownList', () => {
+ expect(commentTypeToggle.dropdownList).toBe(dropdownList);
+ });
+
+ it('sets .button', () => {
+ expect(commentTypeToggle.button).toBe(button);
+ });
+ });
+
+ describe('initDroplab', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {
+ querySelector: jest.fn(),
+ };
+ const dropdownTrigger = {};
+ const button = {};
+ const reopenItem = {};
+ const closeItem = {};
+ const config = {};
+
+ beforeEach(() => {
+ jest.spyOn(DropLab.prototype, 'init').mockImplementation(() => {});
+ dropdownList.querySelector.mockReturnValueOnce(reopenItem).mockReturnValueOnce(closeItem);
+
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ jest.spyOn(closeReopenReportToggle, 'setConfig').mockReturnValue(config);
+
+ closeReopenReportToggle.initDroplab();
+ });
+
+ it('sets .reopenItem and .closeItem', () => {
+ expect(dropdownList.querySelector).toHaveBeenCalledWith('.reopen-item');
+ expect(dropdownList.querySelector).toHaveBeenCalledWith('.close-item');
+ expect(closeReopenReportToggle.reopenItem).toBe(reopenItem);
+ expect(closeReopenReportToggle.closeItem).toBe(closeItem);
+ });
+
+ it('sets .droplab', () => {
+ expect(closeReopenReportToggle.droplab).toEqual(expect.any(Object));
+ });
+
+ it('calls .setConfig', () => {
+ expect(closeReopenReportToggle.setConfig).toHaveBeenCalled();
+ });
+
+ it('calls droplab.init', () => {
+ expect(DropLab.prototype.init).toHaveBeenCalledWith(
+ dropdownTrigger,
+ dropdownList,
+ expect.any(Array),
+ config,
+ );
+ });
+ });
+
+ describe('updateButton', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {};
+ const button = {
+ blur: jest.fn(),
+ };
+ const isClosed = true;
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ jest.spyOn(closeReopenReportToggle, 'toggleButtonType').mockImplementation(() => {});
+
+ closeReopenReportToggle.updateButton(isClosed);
+ });
+
+ it('calls .toggleButtonType', () => {
+ expect(closeReopenReportToggle.toggleButtonType).toHaveBeenCalledWith(isClosed);
+ });
+
+ it('calls .button.blur', () => {
+ expect(closeReopenReportToggle.button.blur).toHaveBeenCalled();
+ });
+ });
+
+ describe('toggleButtonType', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {};
+ const button = {};
+ const isClosed = true;
+ const showItem = {
+ click: jest.fn(),
+ };
+ const hideItem = {};
+ showItem.classList = {
+ add: jest.fn(),
+ remove: jest.fn(),
+ };
+ hideItem.classList = {
+ add: jest.fn(),
+ remove: jest.fn(),
+ };
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ jest.spyOn(closeReopenReportToggle, 'getButtonTypes').mockReturnValue([showItem, hideItem]);
+
+ closeReopenReportToggle.toggleButtonType(isClosed);
+ });
+
+ it('calls .getButtonTypes', () => {
+ expect(closeReopenReportToggle.getButtonTypes).toHaveBeenCalledWith(isClosed);
+ });
+
+ it('removes hide class and add selected class to showItem, opposite for hideItem', () => {
+ expect(showItem.classList.remove).toHaveBeenCalledWith('hidden');
+ expect(showItem.classList.add).toHaveBeenCalledWith('droplab-item-selected');
+ expect(hideItem.classList.add).toHaveBeenCalledWith('hidden');
+ expect(hideItem.classList.remove).toHaveBeenCalledWith('droplab-item-selected');
+ });
+
+ it('clicks the showItem', () => {
+ expect(showItem.click).toHaveBeenCalled();
+ });
+ });
+
+ describe('getButtonTypes', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {};
+ const button = {};
+ const reopenItem = {};
+ const closeItem = {};
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ closeReopenReportToggle.reopenItem = reopenItem;
+ closeReopenReportToggle.closeItem = closeItem;
+ });
+
+ it('returns reopenItem, closeItem if isClosed is true', () => {
+ const buttonTypes = closeReopenReportToggle.getButtonTypes(true);
+
+ expect(buttonTypes).toEqual([reopenItem, closeItem]);
+ });
+
+ it('returns closeItem, reopenItem if isClosed is false', () => {
+ const buttonTypes = closeReopenReportToggle.getButtonTypes(false);
+
+ expect(buttonTypes).toEqual([closeItem, reopenItem]);
+ });
+ });
+
+ describe('setDisable', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {
+ setAttribute: jest.fn(),
+ removeAttribute: jest.fn(),
+ };
+ const button = {
+ setAttribute: jest.fn(),
+ removeAttribute: jest.fn(),
+ };
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+ });
+
+ it('disable .button and .dropdownTrigger if shouldDisable is true', () => {
+ closeReopenReportToggle.setDisable(true);
+
+ expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true');
+ expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true');
+ });
+
+ it('disable .button and .dropdownTrigger if shouldDisable is undefined', () => {
+ closeReopenReportToggle.setDisable();
+
+ expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true');
+ expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true');
+ });
+
+ it('enable .button and .dropdownTrigger if shouldDisable is false', () => {
+ closeReopenReportToggle.setDisable(false);
+
+ expect(button.removeAttribute).toHaveBeenCalledWith('disabled');
+ expect(dropdownTrigger.removeAttribute).toHaveBeenCalledWith('disabled');
+ });
+ });
+
+ describe('setConfig', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {};
+ const button = {};
+ let config;
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ config = closeReopenReportToggle.setConfig();
+ });
+
+ it('returns a config object', () => {
+ expect(config).toEqual({
+ InputSetter: [
+ {
+ input: button,
+ valueAttribute: 'data-text',
+ inputAttribute: 'data-value',
+ },
+ {
+ input: button,
+ valueAttribute: 'data-text',
+ inputAttribute: 'title',
+ },
+ {
+ input: button,
+ valueAttribute: 'data-button-class',
+ inputAttribute: 'class',
+ },
+ {
+ input: dropdownTrigger,
+ valueAttribute: 'data-toggle-class',
+ inputAttribute: 'class',
+ },
+ {
+ input: button,
+ valueAttribute: 'data-url',
+ inputAttribute: 'href',
+ },
+ {
+ input: button,
+ valueAttribute: 'data-method',
+ inputAttribute: 'data-method',
+ },
+ ],
+ });
+ });
+ });
+});
diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js
index 782e5215ad8..33b30891d5e 100644
--- a/spec/frontend/clusters/components/applications_spec.js
+++ b/spec/frontend/clusters/components/applications_spec.js
@@ -8,6 +8,7 @@ import eventHub from '~/clusters/event_hub';
import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue';
import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue';
import IngressModsecuritySettings from '~/clusters/components/ingress_modsecurity_settings.vue';
+import FluentdOutputSettings from '~/clusters/components/fluentd_output_settings.vue';
describe('Applications', () => {
let vm;
@@ -67,6 +68,10 @@ describe('Applications', () => {
it('renders a row for Elastic Stack', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull();
});
+
+ it('renders a row for Fluentd', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-fluentd')).not.toBeNull();
+ });
});
describe('Group cluster applications', () => {
@@ -112,6 +117,10 @@ describe('Applications', () => {
it('renders a row for Elastic Stack', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull();
});
+
+ it('renders a row for Fluentd', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-fluentd')).not.toBeNull();
+ });
});
describe('Instance cluster applications', () => {
@@ -157,6 +166,10 @@ describe('Applications', () => {
it('renders a row for Elastic Stack', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull();
});
+
+ it('renders a row for Fluentd', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-fluentd')).not.toBeNull();
+ });
});
describe('Helm application', () => {
@@ -240,6 +253,7 @@ describe('Applications', () => {
jupyter: { title: 'JupyterHub', hostname: '' },
knative: { title: 'Knative', hostname: '' },
elastic_stack: { title: 'Elastic Stack' },
+ fluentd: { title: 'Fluentd' },
},
});
@@ -539,4 +553,23 @@ describe('Applications', () => {
});
});
});
+
+ describe('Fluentd application', () => {
+ const propsData = {
+ applications: {
+ ...APPLICATIONS_MOCK_STATE,
+ },
+ };
+
+ let wrapper;
+ beforeEach(() => {
+ wrapper = shallowMount(Applications, { propsData });
+ });
+ afterEach(() => {
+ wrapper.destroy();
+ });
+ it('renders the correct Component', () => {
+ expect(wrapper.contains(FluentdOutputSettings)).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/clusters/components/fluentd_output_settings_spec.js b/spec/frontend/clusters/components/fluentd_output_settings_spec.js
new file mode 100644
index 00000000000..5e27cc49049
--- /dev/null
+++ b/spec/frontend/clusters/components/fluentd_output_settings_spec.js
@@ -0,0 +1,186 @@
+import { shallowMount } from '@vue/test-utils';
+import FluentdOutputSettings from '~/clusters/components/fluentd_output_settings.vue';
+import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants';
+import { GlAlert, GlDropdown, GlFormCheckbox } from '@gitlab/ui';
+import eventHub from '~/clusters/event_hub';
+
+const { UPDATING } = APPLICATION_STATUS;
+
+describe('FluentdOutputSettings', () => {
+ let wrapper;
+
+ const defaultSettings = {
+ protocol: 'tcp',
+ host: '127.0.0.1',
+ port: 514,
+ wafLogEnabled: true,
+ ciliumLogEnabled: false,
+ };
+ const defaultProps = {
+ status: 'installable',
+ updateFailed: false,
+ ...defaultSettings,
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(FluentdOutputSettings, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+ const updateComponentPropsFromEvent = () => {
+ const { isEditingSettings, ...props } = eventHub.$emit.mock.calls[0][1];
+ wrapper.setProps(props);
+ };
+ const findSaveButton = () => wrapper.find({ ref: 'saveBtn' });
+ const findCancelButton = () => wrapper.find({ ref: 'cancelBtn' });
+ const findProtocolDropdown = () => wrapper.find(GlDropdown);
+ const findCheckbox = name =>
+ wrapper.findAll(GlFormCheckbox).wrappers.find(x => x.text() === name);
+ const findHost = () => wrapper.find('#fluentd-host');
+ const findPort = () => wrapper.find('#fluentd-port');
+ const changeCheckbox = checkbox => {
+ const currentValue = checkbox.attributes('checked')?.toString() === 'true';
+ checkbox.vm.$emit('input', !currentValue);
+ };
+ const changeInput = ({ element }, val) => {
+ element.value = val;
+ element.dispatchEvent(new Event('input'));
+ };
+ const changePort = val => changeInput(findPort(), val);
+ const changeHost = val => changeInput(findHost(), val);
+ const changeProtocol = idx => findProtocolDropdown().vm.$children[idx].$emit('click');
+ const toApplicationSettings = ({ wafLogEnabled, ciliumLogEnabled, ...settings }) => ({
+ ...settings,
+ waf_log_enabled: wafLogEnabled,
+ cilium_log_enabled: ciliumLogEnabled,
+ });
+
+ describe('when fluentd is installed', () => {
+ beforeEach(() => {
+ createComponent({ status: 'installed' });
+ jest.spyOn(eventHub, '$emit');
+ });
+
+ it('does not render save and cancel buttons', () => {
+ expect(findSaveButton().exists()).toBe(false);
+ expect(findCancelButton().exists()).toBe(false);
+ });
+
+ describe.each`
+ desc | changeFn | key | value
+ ${'when protocol dropdown is triggered'} | ${() => changeProtocol(1)} | ${'protocol'} | ${'udp'}
+ ${'when host is changed'} | ${() => changeHost('test-host')} | ${'host'} | ${'test-host'}
+ ${'when port is changed'} | ${() => changePort(123)} | ${'port'} | ${123}
+ ${'when wafLogEnabled changes'} | ${() => changeCheckbox(findCheckbox('Send ModSecurity Logs'))} | ${'wafLogEnabled'} | ${!defaultSettings.wafLogEnabled}
+ ${'when ciliumLogEnabled changes'} | ${() => changeCheckbox(findCheckbox('Send Cilium Logs'))} | ${'ciliumLogEnabled'} | ${!defaultSettings.ciliumLogEnabled}
+ `('$desc', ({ changeFn, key, value }) => {
+ beforeEach(() => {
+ changeFn();
+ });
+
+ it('triggers set event to be propagated with the current value', () => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('setFluentdSettings', {
+ [key]: value,
+ isEditingSettings: true,
+ });
+ });
+
+ describe('when value is updated from store', () => {
+ beforeEach(() => {
+ updateComponentPropsFromEvent();
+ });
+
+ it('enables save and cancel buttons', () => {
+ expect(findSaveButton().exists()).toBe(true);
+ expect(findSaveButton().attributes().disabled).toBeUndefined();
+ expect(findCancelButton().exists()).toBe(true);
+ expect(findCancelButton().attributes().disabled).toBeUndefined();
+ });
+
+ describe('and the save changes button is clicked', () => {
+ beforeEach(() => {
+ eventHub.$emit.mockClear();
+ findSaveButton().vm.$emit('click');
+ });
+
+ it('triggers save event and pass current values', () => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('updateApplication', {
+ id: FLUENTD,
+ params: toApplicationSettings({
+ ...defaultSettings,
+ [key]: value,
+ }),
+ });
+ });
+ });
+
+ describe('and the cancel button is clicked', () => {
+ beforeEach(() => {
+ eventHub.$emit.mockClear();
+ findCancelButton().vm.$emit('click');
+ });
+
+ it('triggers reset event', () => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('setFluentdSettings', {
+ ...defaultSettings,
+ isEditingSettings: false,
+ });
+ });
+
+ describe('when value is updated from store', () => {
+ beforeEach(() => {
+ updateComponentPropsFromEvent();
+ });
+
+ it('does not render save and cancel buttons', () => {
+ expect(findSaveButton().exists()).toBe(false);
+ expect(findCancelButton().exists()).toBe(false);
+ });
+ });
+ });
+ });
+ });
+
+ describe(`when fluentd status is ${UPDATING}`, () => {
+ beforeEach(() => {
+ createComponent({ installed: true, status: UPDATING });
+ });
+
+ it('renders loading spinner in save button', () => {
+ expect(findSaveButton().props('loading')).toBe(true);
+ });
+
+ it('renders disabled save button', () => {
+ expect(findSaveButton().props('disabled')).toBe(true);
+ });
+
+ it('renders save button with "Saving" label', () => {
+ expect(findSaveButton().text()).toBe('Saving');
+ });
+ });
+
+ describe('when fluentd fails to update', () => {
+ beforeEach(() => {
+ createComponent({ updateFailed: true });
+ });
+
+ it('displays a error message', () => {
+ expect(wrapper.contains(GlAlert)).toBe(true);
+ });
+ });
+ });
+
+ describe('when fluentd is not installed', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('does not render the save button', () => {
+ expect(findSaveButton().exists()).toBe(false);
+ expect(findCancelButton().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/clusters/components/knative_domain_editor_spec.js b/spec/frontend/clusters/components/knative_domain_editor_spec.js
index 2de04f7da1f..73d08661199 100644
--- a/spec/frontend/clusters/components/knative_domain_editor_spec.js
+++ b/spec/frontend/clusters/components/knative_domain_editor_spec.js
@@ -93,7 +93,7 @@ describe('KnativeDomainEditor', () => {
it('displays toast indicating a successful update', () => {
wrapper.vm.$toast = { show: jest.fn() };
- wrapper.setProps({ knative: Object.assign({ updateSuccessful: true }, knative) });
+ wrapper.setProps({ knative: { updateSuccessful: true, ...knative } });
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js
index 52d78ea1176..c5ec3f6e6a8 100644
--- a/spec/frontend/clusters/services/mock_data.js
+++ b/spec/frontend/clusters/services/mock_data.js
@@ -159,6 +159,7 @@ const APPLICATIONS_MOCK_STATE = {
jupyter: { title: 'JupyterHub', status: 'installable', hostname: '' },
knative: { title: 'Knative ', status: 'installable', hostname: '' },
elastic_stack: { title: 'Elastic Stack', status: 'installable' },
+ fluentd: { title: 'Fluentd', status: 'installable' },
};
export { CLUSTERS_MOCK_DATA, DEFAULT_APPLICATION_STATE, APPLICATIONS_MOCK_STATE };
diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js
index 9fafc688af9..36e99c37be5 100644
--- a/spec/frontend/clusters/stores/clusters_store_spec.js
+++ b/spec/frontend/clusters/stores/clusters_store_spec.js
@@ -121,6 +121,24 @@ describe('Clusters Store', () => {
uninstallFailed: false,
validationError: null,
},
+ fluentd: {
+ title: 'Fluentd',
+ status: null,
+ statusReason: null,
+ requestReason: null,
+ port: null,
+ ciliumLogEnabled: null,
+ host: null,
+ protocol: null,
+ installed: false,
+ isEditingSettings: false,
+ installFailed: false,
+ uninstallable: false,
+ uninstallSuccessful: false,
+ uninstallFailed: false,
+ validationError: null,
+ wafLogEnabled: null,
+ },
jupyter: {
title: 'JupyterHub',
status: mockResponseData.applications[4].status,
diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js
index 85c86b2c0a9..e2d2e4b73b3 100644
--- a/spec/frontend/clusters_list/components/clusters_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_spec.js
@@ -1,46 +1,68 @@
-import Vuex from 'vuex';
-import { createLocalVue, mount } from '@vue/test-utils';
-import { GlTable, GlLoadingIcon } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
import Clusters from '~/clusters_list/components/clusters.vue';
-import mockData from '../mock_data';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
+import ClusterStore from '~/clusters_list/store';
+import MockAdapter from 'axios-mock-adapter';
+import { apiData } from '../mock_data';
+import { mount } from '@vue/test-utils';
+import { GlLoadingIcon, GlTable, GlPagination } from '@gitlab/ui';
describe('Clusters', () => {
+ let mock;
+ let store;
let wrapper;
- const findTable = () => wrapper.find(GlTable);
+ const endpoint = 'some/endpoint';
+
const findLoader = () => wrapper.find(GlLoadingIcon);
+ const findPaginatedButtons = () => wrapper.find(GlPagination);
+ const findTable = () => wrapper.find(GlTable);
const findStatuses = () => findTable().findAll('.js-status');
- const mountComponent = _state => {
- const state = { clusters: mockData, endpoint: 'some/endpoint', ..._state };
- const store = new Vuex.Store({
- state,
- });
+ const mockPollingApi = (response, body, header) => {
+ mock.onGet(`${endpoint}?page=${header['x-page']}`).reply(response, body, header);
+ };
- wrapper = mount(Clusters, { localVue, store });
+ const mountWrapper = () => {
+ store = ClusterStore({ endpoint });
+ wrapper = mount(Clusters, { store });
+ return axios.waitForAll();
};
beforeEach(() => {
- mountComponent({ loading: false });
+ mock = new MockAdapter(axios);
+ mockPollingApi(200, apiData, {
+ 'x-total': apiData.clusters.length,
+ 'x-per-page': 20,
+ 'x-page': 1,
+ });
+
+ return mountWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
});
describe('clusters table', () => {
- it('displays a loader instead of the table while loading', () => {
- mountComponent({ loading: true });
- expect(findLoader().exists()).toBe(true);
- expect(findTable().exists()).toBe(false);
+ describe('when data is loading', () => {
+ beforeEach(() => {
+ wrapper.vm.$store.state.loading = true;
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays a loader instead of the table while loading', () => {
+ expect(findLoader().exists()).toBe(true);
+ expect(findTable().exists()).toBe(false);
+ });
});
it('displays a table component', () => {
expect(findTable().exists()).toBe(true);
- expect(findTable().exists()).toBe(true);
});
it('renders the correct table headers', () => {
- const tableHeaders = wrapper.vm.$options.fields;
+ const tableHeaders = wrapper.vm.fields;
const headers = findTable().findAll('th');
expect(headers.length).toBe(tableHeaders.length);
@@ -62,7 +84,8 @@ describe('Clusters', () => {
${'unreachable'} | ${'bg-danger'} | ${1}
${'authentication_failure'} | ${'bg-warning'} | ${2}
${'deleting'} | ${null} | ${3}
- ${'connected'} | ${'bg-success'} | ${4}
+ ${'created'} | ${'bg-success'} | ${4}
+ ${'default'} | ${'bg-white'} | ${5}
`('renders a status for each cluster', ({ statusName, className, lineNumber }) => {
const statuses = findStatuses();
const status = statuses.at(lineNumber);
@@ -75,4 +98,47 @@ describe('Clusters', () => {
}
});
});
+
+ describe('pagination', () => {
+ const perPage = apiData.clusters.length;
+ const totalFirstPage = 100;
+ const totalSecondPage = 500;
+
+ beforeEach(() => {
+ mockPollingApi(200, apiData, {
+ 'x-total': totalFirstPage,
+ 'x-per-page': perPage,
+ 'x-page': 1,
+ });
+ return mountWrapper();
+ });
+
+ it('should load to page 1 with header values', () => {
+ const buttons = findPaginatedButtons();
+
+ expect(buttons.props('perPage')).toBe(perPage);
+ expect(buttons.props('totalItems')).toBe(totalFirstPage);
+ expect(buttons.props('value')).toBe(1);
+ });
+
+ describe('when updating currentPage', () => {
+ beforeEach(() => {
+ mockPollingApi(200, apiData, {
+ 'x-total': totalSecondPage,
+ 'x-per-page': perPage,
+ 'x-page': 2,
+ });
+ wrapper.setData({ currentPage: 2 });
+ return axios.waitForAll();
+ });
+
+ it('should change pagination when currentPage changes', () => {
+ const buttons = findPaginatedButtons();
+
+ expect(buttons.props('perPage')).toBe(perPage);
+ expect(buttons.props('totalItems')).toBe(totalSecondPage);
+ expect(buttons.props('value')).toBe(2);
+ });
+ });
+ });
});
diff --git a/spec/frontend/clusters_list/mock_data.js b/spec/frontend/clusters_list/mock_data.js
index 5398975d81c..9a90a378f31 100644
--- a/spec/frontend/clusters_list/mock_data.js
+++ b/spec/frontend/clusters_list/mock_data.js
@@ -1,4 +1,4 @@
-export default [
+export const clusterList = [
{
name: 'My Cluster 1',
environmentScope: '*',
@@ -40,8 +40,22 @@ export default [
environmentScope: 'development',
size: '12',
clusterType: 'project_type',
- status: 'connected',
+ status: 'created',
+ cpu: '6 (100% free)',
+ memory: '20.12 (35% free)',
+ },
+ {
+ name: 'My Cluster 6',
+ environmentScope: '*',
+ size: '1',
+ clusterType: 'project_type',
+ status: 'cleanup_ongoing',
cpu: '6 (100% free)',
memory: '20.12 (35% free)',
},
];
+
+export const apiData = {
+ clusters: clusterList,
+ has_ancestor_clusters: false,
+};
diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js
index e903200bf1d..70766af3ec4 100644
--- a/spec/frontend/clusters_list/store/actions_spec.js
+++ b/spec/frontend/clusters_list/store/actions_spec.js
@@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import flashError from '~/flash';
import testAction from 'helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
+import { apiData } from '../mock_data';
import * as types from '~/clusters_list/store/mutation_types';
import * as actions from '~/clusters_list/store/actions';
@@ -10,8 +11,6 @@ jest.mock('~/flash.js');
describe('Clusters store actions', () => {
describe('fetchClusters', () => {
let mock;
- const endpoint = '/clusters';
- const clusters = [{ name: 'test' }];
beforeEach(() => {
mock = new MockAdapter(axios);
@@ -20,14 +19,29 @@ describe('Clusters store actions', () => {
afterEach(() => mock.restore());
it('should commit SET_CLUSTERS_DATA with received response', done => {
- mock.onGet().reply(200, clusters);
+ const headers = {
+ 'x-total': apiData.clusters.length,
+ 'x-per-page': 20,
+ 'x-page': 1,
+ };
+
+ const paginationInformation = {
+ nextPage: NaN,
+ page: 1,
+ perPage: 20,
+ previousPage: NaN,
+ total: apiData.clusters.length,
+ totalPages: NaN,
+ };
+
+ mock.onGet().reply(200, apiData, headers);
testAction(
actions.fetchClusters,
- { endpoint },
+ { endpoint: apiData.endpoint },
{},
[
- { type: types.SET_CLUSTERS_DATA, payload: clusters },
+ { type: types.SET_CLUSTERS_DATA, payload: { data: apiData, paginationInformation } },
{ type: types.SET_LOADING_STATE, payload: false },
],
[],
@@ -38,13 +52,10 @@ describe('Clusters store actions', () => {
it('should show flash on API error', done => {
mock.onGet().reply(400, 'Not Found');
- testAction(actions.fetchClusters, { endpoint }, {}, [], [], () => {
+ testAction(actions.fetchClusters, { endpoint: apiData.endpoint }, {}, [], [], () => {
expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error'));
done();
});
});
});
});
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
diff --git a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
index c1534022242..c9fdd388585 100644
--- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
+++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
@@ -23,17 +23,20 @@ exports[`Code navigation popover component renders popover 1`] = `
<div
class="popover-body"
>
- <gl-deprecated-button-stub
+ <gl-button-stub
+ category="tertiary"
class="w-100"
- href="http://test.com"
- size="md"
+ data-testid="go-to-definition-btn"
+ href="http://gitlab.com/test.js#L20"
+ icon=""
+ size="medium"
target="_blank"
variant="default"
>
Go to definition
- </gl-deprecated-button-stub>
+ </gl-button-stub>
</div>
</div>
`;
diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js
index d5693cc4173..6dfc81dcc40 100644
--- a/spec/frontend/code_navigation/components/app_spec.js
+++ b/spec/frontend/code_navigation/components/app_spec.js
@@ -48,6 +48,7 @@ describe('Code navigation app component', () => {
factory({
currentDefinition: { hover: 'console' },
currentDefinitionPosition: { x: 0 },
+ currentBlobPath: 'index.js',
});
expect(wrapper.find(Popover).exists()).toBe(true);
diff --git a/spec/frontend/code_navigation/components/popover_spec.js b/spec/frontend/code_navigation/components/popover_spec.js
index df3bbc7c1c6..858e94cf155 100644
--- a/spec/frontend/code_navigation/components/popover_spec.js
+++ b/spec/frontend/code_navigation/components/popover_spec.js
@@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import Popover from '~/code_navigation/components/popover.vue';
-const DEFINITION_PATH_PREFIX = 'http:/';
+const DEFINITION_PATH_PREFIX = 'http://gitlab.com';
const MOCK_CODE_DATA = Object.freeze({
hover: [
@@ -10,7 +10,7 @@ const MOCK_CODE_DATA = Object.freeze({
value: 'console.log',
},
],
- definition_path: 'test.com',
+ definition_path: 'test.js#L20',
});
const MOCK_DOCS_DATA = Object.freeze({
@@ -20,13 +20,15 @@ const MOCK_DOCS_DATA = Object.freeze({
value: 'console.log',
},
],
- definition_path: 'test.com',
+ definition_path: 'test.js#L20',
});
let wrapper;
-function factory(position, data, definitionPathPrefix) {
- wrapper = shallowMount(Popover, { propsData: { position, data, definitionPathPrefix } });
+function factory({ position, data, definitionPathPrefix, blobPath = 'index.js' }) {
+ wrapper = shallowMount(Popover, {
+ propsData: { position, data, definitionPathPrefix, blobPath },
+ });
}
describe('Code navigation popover component', () => {
@@ -35,14 +37,33 @@ describe('Code navigation popover component', () => {
});
it('renders popover', () => {
- factory({ x: 0, y: 0, height: 0 }, MOCK_CODE_DATA, DEFINITION_PATH_PREFIX);
+ factory({
+ position: { x: 0, y: 0, height: 0 },
+ data: MOCK_CODE_DATA,
+ definitionPathPrefix: DEFINITION_PATH_PREFIX,
+ });
expect(wrapper.element).toMatchSnapshot();
});
+ it('renders link with hash to current file', () => {
+ factory({
+ position: { x: 0, y: 0, height: 0 },
+ data: MOCK_CODE_DATA,
+ definitionPathPrefix: DEFINITION_PATH_PREFIX,
+ blobPath: 'test.js',
+ });
+
+ expect(wrapper.find('[data-testid="go-to-definition-btn"]').attributes('href')).toBe('#L20');
+ });
+
describe('code output', () => {
it('renders code output', () => {
- factory({ x: 0, y: 0, height: 0 }, MOCK_CODE_DATA, DEFINITION_PATH_PREFIX);
+ factory({
+ position: { x: 0, y: 0, height: 0 },
+ data: MOCK_CODE_DATA,
+ definitionPathPrefix: DEFINITION_PATH_PREFIX,
+ });
expect(wrapper.find({ ref: 'code-output' }).exists()).toBe(true);
expect(wrapper.find({ ref: 'doc-output' }).exists()).toBe(false);
@@ -51,7 +72,11 @@ describe('Code navigation popover component', () => {
describe('documentation output', () => {
it('renders code output', () => {
- factory({ x: 0, y: 0, height: 0 }, MOCK_DOCS_DATA, DEFINITION_PATH_PREFIX);
+ factory({
+ position: { x: 0, y: 0, height: 0 },
+ data: MOCK_DOCS_DATA,
+ definitionPathPrefix: DEFINITION_PATH_PREFIX,
+ });
expect(wrapper.find({ ref: 'code-output' }).exists()).toBe(false);
expect(wrapper.find({ ref: 'doc-output' }).exists()).toBe(true);
diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js
index 6d2ede6dda7..4cf77ed1be5 100644
--- a/spec/frontend/code_navigation/store/actions_spec.js
+++ b/spec/frontend/code_navigation/store/actions_spec.js
@@ -143,6 +143,16 @@ describe('Code navigation actions', () => {
expect(addInteractionClass.mock.calls[0]).toEqual(['index.js', 'test']);
expect(addInteractionClass.mock.calls[1]).toEqual(['index.js', 'console.log']);
});
+
+ it('does not call addInteractionClass when no data exists', () => {
+ const state = {
+ data: null,
+ };
+
+ actions.showBlobInteractionZones({ state }, 'index.js');
+
+ expect(addInteractionClass).not.toHaveBeenCalled();
+ });
});
describe('showDefinition', () => {
@@ -173,7 +183,11 @@ describe('Code navigation actions', () => {
[
{
type: 'SET_CURRENT_DEFINITION',
- payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } },
+ payload: {
+ blobPath: 'index.js',
+ definition: { hover: 'test' },
+ position: { height: 0, x: 0, y: 0 },
+ },
},
],
[],
@@ -193,7 +207,11 @@ describe('Code navigation actions', () => {
[
{
type: 'SET_CURRENT_DEFINITION',
- payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } },
+ payload: {
+ blobPath: 'index.js',
+ definition: { hover: 'test' },
+ position: { height: 0, x: 0, y: 0 },
+ },
},
],
[],
@@ -214,7 +232,11 @@ describe('Code navigation actions', () => {
[
{
type: 'SET_CURRENT_DEFINITION',
- payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } },
+ payload: {
+ blobPath: 'index.js',
+ definition: { hover: 'test' },
+ position: { height: 0, x: 0, y: 0 },
+ },
},
],
[],
diff --git a/spec/frontend/commit/pipelines/pipelines_spec.js b/spec/frontend/commit/pipelines/pipelines_spec.js
index b88cba90b87..86ae207e7b7 100644
--- a/spec/frontend/commit/pipelines/pipelines_spec.js
+++ b/spec/frontend/commit/pipelines/pipelines_spec.js
@@ -118,7 +118,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
let pipelineCopy;
beforeEach(() => {
- pipelineCopy = Object.assign({}, pipeline);
+ pipelineCopy = { ...pipeline };
});
describe('when latest pipeline has detached flag and canRunPipeline is true', () => {
@@ -128,12 +128,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
- vm = mountComponent(
- PipelinesTable,
- Object.assign({}, props, {
- canRunPipeline: true,
- }),
- );
+ vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: true });
setImmediate(() => {
expect(vm.$el.querySelector('.js-run-mr-pipeline')).not.toBeNull();
@@ -149,12 +144,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
- vm = mountComponent(
- PipelinesTable,
- Object.assign({}, props, {
- canRunPipeline: false,
- }),
- );
+ vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: false });
setImmediate(() => {
expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull();
@@ -170,12 +160,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
- vm = mountComponent(
- PipelinesTable,
- Object.assign({}, props, {
- canRunPipeline: true,
- }),
- );
+ vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: true });
setImmediate(() => {
expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull();
@@ -191,12 +176,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
- vm = mountComponent(
- PipelinesTable,
- Object.assign({}, props, {
- canRunPipeline: false,
- }),
- );
+ vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: false });
setImmediate(() => {
expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull();
@@ -211,14 +191,12 @@ describe('Pipelines table in Commits and Merge requests', () => {
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
- vm = mountComponent(
- PipelinesTable,
- Object.assign({}, props, {
- canRunPipeline: true,
- projectId: '5',
- mergeRequestId: 3,
- }),
- );
+ vm = mountComponent(PipelinesTable, {
+ ...props,
+ canRunPipeline: true,
+ projectId: '5',
+ mergeRequestId: 3,
+ });
});
it('updates the loading state', done => {
diff --git a/spec/javascripts/commit_merge_requests_spec.js b/spec/frontend/commit_merge_requests_spec.js
index 82968e028d1..82968e028d1 100644
--- a/spec/javascripts/commit_merge_requests_spec.js
+++ b/spec/frontend/commit_merge_requests_spec.js
diff --git a/spec/frontend/commits_spec.js b/spec/frontend/commits_spec.js
new file mode 100644
index 00000000000..42bd37570b1
--- /dev/null
+++ b/spec/frontend/commits_spec.js
@@ -0,0 +1,98 @@
+import $ from 'jquery';
+import 'vendor/jquery.endless-scroll';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import CommitsList from '~/commits';
+import Pager from '~/pager';
+
+describe('Commits List', () => {
+ let commitsList;
+
+ beforeEach(() => {
+ setFixtures(`
+ <form class="commits-search-form" action="/h5bp/html5-boilerplate/commits/master">
+ <input id="commits-search">
+ </form>
+ <ol id="commits-list"></ol>
+ `);
+ jest.spyOn(Pager, 'init').mockImplementation(() => {});
+ commitsList = new CommitsList(25);
+ });
+
+ it('should be defined', () => {
+ expect(CommitsList).toBeDefined();
+ });
+
+ describe('processCommits', () => {
+ it('should join commit headers', () => {
+ commitsList.$contentList = $(`
+ <div>
+ <li class="commit-header" data-day="2016-09-20">
+ <span class="day">20 Sep, 2016</span>
+ <span class="commits-count">1 commit</span>
+ </li>
+ <li class="commit"></li>
+ </div>
+ `);
+
+ const data = `
+ <li class="commit-header" data-day="2016-09-20">
+ <span class="day">20 Sep, 2016</span>
+ <span class="commits-count">1 commit</span>
+ </li>
+ <li class="commit"></li>
+ `;
+
+ // The last commit header should be removed
+ // since the previous one has the same data-day value.
+ expect(commitsList.processCommits(data).find('li.commit-header').length).toBe(0);
+ });
+ });
+
+ describe('on entering input', () => {
+ let ajaxSpy;
+ let mock;
+
+ beforeEach(() => {
+ commitsList.searchField.val('');
+
+ jest.spyOn(window.history, 'replaceState').mockImplementation(() => {});
+ mock = new MockAdapter(axios);
+
+ mock.onGet('/h5bp/html5-boilerplate/commits/master').reply(200, {
+ html: '<li>Result</li>',
+ });
+
+ ajaxSpy = jest.spyOn(axios, 'get');
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should save the last search string', done => {
+ commitsList.searchField.val('GitLab');
+ commitsList
+ .filterResults()
+ .then(() => {
+ expect(ajaxSpy).toHaveBeenCalled();
+ expect(commitsList.lastSearch).toEqual('GitLab');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('should not make ajax call if the input does not change', done => {
+ commitsList
+ .filterResults()
+ .then(() => {
+ expect(ajaxSpy).not.toHaveBeenCalled();
+ expect(commitsList.lastSearch).toEqual('');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js
index fe3e2132d9d..55437da837c 100644
--- a/spec/frontend/contributors/store/actions_spec.js
+++ b/spec/frontend/contributors/store/actions_spec.js
@@ -55,6 +55,3 @@ describe('Contributors store actions', () => {
});
});
});
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
diff --git a/spec/frontend/contributors/store/getters_spec.js b/spec/frontend/contributors/store/getters_spec.js
index e6342a669b7..a4202e0ef4b 100644
--- a/spec/frontend/contributors/store/getters_spec.js
+++ b/spec/frontend/contributors/store/getters_spec.js
@@ -74,6 +74,3 @@ describe('Contributors Store Getters', () => {
});
});
});
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
diff --git a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js
index 490a2775b67..0ef09b4b87e 100644
--- a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js
@@ -75,7 +75,7 @@ describe('awsServicesFacade', () => {
});
it('return list of regions where each item has a name and value', () => {
- expect(fetchRoles()).resolves.toEqual(rolesOutput);
+ return expect(fetchRoles()).resolves.toEqual(rolesOutput);
});
});
@@ -91,7 +91,7 @@ describe('awsServicesFacade', () => {
});
it('return list of roles where each item has a name and value', () => {
- expect(fetchRegions()).resolves.toEqual(regionsOutput);
+ return expect(fetchRegions()).resolves.toEqual(regionsOutput);
});
});
@@ -112,7 +112,7 @@ describe('awsServicesFacade', () => {
});
it('return list of key pairs where each item has a name and value', () => {
- expect(fetchKeyPairs({ region })).resolves.toEqual(keyPairsOutput);
+ return expect(fetchKeyPairs({ region })).resolves.toEqual(keyPairsOutput);
});
});
@@ -133,7 +133,7 @@ describe('awsServicesFacade', () => {
});
it('return list of vpcs where each item has a name and value', () => {
- expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput);
+ return expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput);
});
});
@@ -151,7 +151,7 @@ describe('awsServicesFacade', () => {
});
it('uses name tag value as the vpc name', () => {
- expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput);
+ return expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput);
});
});
@@ -167,7 +167,7 @@ describe('awsServicesFacade', () => {
});
it('return list of subnets where each item has a name and value', () => {
- expect(fetchSubnets({ region, vpc })).resolves.toEqual(subnetsOutput);
+ return expect(fetchSubnets({ region, vpc })).resolves.toEqual(subnetsOutput);
});
});
@@ -189,7 +189,7 @@ describe('awsServicesFacade', () => {
});
it('return list of security groups where each item has a name and value', () => {
- expect(fetchSecurityGroups({ region, vpc })).resolves.toEqual(securityGroupsOutput);
+ return expect(fetchSecurityGroups({ region, vpc })).resolves.toEqual(securityGroupsOutput);
});
});
});
diff --git a/spec/javascripts/create_item_dropdown_spec.js b/spec/frontend/create_item_dropdown_spec.js
index a814952faab..a814952faab 100644
--- a/spec/javascripts/create_item_dropdown_spec.js
+++ b/spec/frontend/create_item_dropdown_spec.js
diff --git a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js
index 61cbef0c557..79c37293fe5 100644
--- a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js
+++ b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js
@@ -152,7 +152,6 @@ describe('custom metrics form fields component', () => {
describe('when query validation is in flight', () => {
beforeEach(() => {
- jest.useFakeTimers();
mountComponent(
{ metricPersisted: true, ...makeFormData({ query: 'validQuery' }) },
{
diff --git a/spec/frontend/deploy_keys/components/action_btn_spec.js b/spec/frontend/deploy_keys/components/action_btn_spec.js
new file mode 100644
index 00000000000..b8211b02464
--- /dev/null
+++ b/spec/frontend/deploy_keys/components/action_btn_spec.js
@@ -0,0 +1,54 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+import eventHub from '~/deploy_keys/eventhub';
+import actionBtn from '~/deploy_keys/components/action_btn.vue';
+
+describe('Deploy keys action btn', () => {
+ const data = getJSONFixture('deploy_keys/keys.json');
+ const deployKey = data.enabled_keys[0];
+ let wrapper;
+
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ beforeEach(() => {
+ wrapper = shallowMount(actionBtn, {
+ propsData: {
+ deployKey,
+ type: 'enable',
+ },
+ slots: {
+ default: 'Enable',
+ },
+ });
+ });
+
+ it('renders the default slot', () => {
+ expect(wrapper.text()).toBe('Enable');
+ });
+
+ it('sends eventHub event with btn type', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+
+ wrapper.trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('enable.key', deployKey, expect.anything());
+ });
+ });
+
+ it('shows loading spinner after click', () => {
+ wrapper.trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ it('disables button after click', () => {
+ wrapper.trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.attributes('disabled')).toBe('disabled');
+ });
+ });
+});
diff --git a/spec/frontend/deploy_keys/components/app_spec.js b/spec/frontend/deploy_keys/components/app_spec.js
new file mode 100644
index 00000000000..291502c9ed7
--- /dev/null
+++ b/spec/frontend/deploy_keys/components/app_spec.js
@@ -0,0 +1,142 @@
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { TEST_HOST } from 'spec/test_constants';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
+import eventHub from '~/deploy_keys/eventhub';
+import deployKeysApp from '~/deploy_keys/components/app.vue';
+
+const TEST_ENDPOINT = `${TEST_HOST}/dummy/`;
+
+describe('Deploy keys app component', () => {
+ const data = getJSONFixture('deploy_keys/keys.json');
+ let wrapper;
+ let mock;
+
+ const mountComponent = () => {
+ wrapper = mount(deployKeysApp, {
+ propsData: {
+ endpoint: TEST_ENDPOINT,
+ projectId: '8',
+ },
+ });
+
+ return waitForPromises();
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(TEST_ENDPOINT).reply(200, data);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ const findLoadingIcon = () => wrapper.find('.gl-spinner');
+ const findKeyPanels = () => wrapper.findAll('.deploy-keys .nav-links li');
+
+ it('renders loading icon while waiting for request', () => {
+ mock.onGet(TEST_ENDPOINT).reply(() => new Promise());
+
+ mountComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ it('renders keys panels', () => {
+ return mountComponent().then(() => {
+ expect(findKeyPanels().length).toBe(3);
+ });
+ });
+
+ it.each`
+ selector | label | count
+ ${'.js-deployKeys-tab-enabled_keys'} | ${'Enabled deploy keys'} | ${1}
+ ${'.js-deployKeys-tab-available_project_keys'} | ${'Privately accessible deploy keys'} | ${0}
+ ${'.js-deployKeys-tab-public_keys'} | ${'Publicly accessible deploy keys'} | ${1}
+ `('$selector title is $label with keys count equal to $count', ({ selector, label, count }) => {
+ return mountComponent().then(() => {
+ const element = wrapper.find(selector);
+ expect(element.exists()).toBe(true);
+ expect(element.text().trim()).toContain(label);
+
+ expect(
+ element
+ .find('.badge')
+ .text()
+ .trim(),
+ ).toBe(count.toString());
+ });
+ });
+
+ it('does not render key panels when keys object is empty', () => {
+ mock.onGet(TEST_ENDPOINT).reply(200, []);
+
+ return mountComponent().then(() => {
+ expect(findKeyPanels().length).toBe(0);
+ });
+ });
+
+ it('re-fetches deploy keys when enabling a key', () => {
+ const key = data.public_keys[0];
+ return mountComponent()
+ .then(() => {
+ jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {});
+ jest.spyOn(wrapper.vm.service, 'enableKey').mockImplementation(() => Promise.resolve());
+
+ eventHub.$emit('enable.key', key);
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.vm.service.enableKey).toHaveBeenCalledWith(key.id);
+ expect(wrapper.vm.service.getKeys).toHaveBeenCalled();
+ });
+ });
+
+ it('re-fetches deploy keys when disabling a key', () => {
+ const key = data.public_keys[0];
+ return mountComponent()
+ .then(() => {
+ jest.spyOn(window, 'confirm').mockReturnValue(true);
+ jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {});
+ jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve());
+
+ eventHub.$emit('disable.key', key);
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.vm.service.disableKey).toHaveBeenCalledWith(key.id);
+ expect(wrapper.vm.service.getKeys).toHaveBeenCalled();
+ });
+ });
+
+ it('calls disableKey when removing a key', () => {
+ const key = data.public_keys[0];
+ return mountComponent()
+ .then(() => {
+ jest.spyOn(window, 'confirm').mockReturnValue(true);
+ jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {});
+ jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve());
+
+ eventHub.$emit('remove.key', key);
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.vm.service.disableKey).toHaveBeenCalledWith(key.id);
+ expect(wrapper.vm.service.getKeys).toHaveBeenCalled();
+ });
+ });
+
+ it('hasKeys returns true when there are keys', () => {
+ return mountComponent().then(() => {
+ expect(wrapper.vm.hasKeys).toEqual(3);
+ });
+ });
+});
diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js
new file mode 100644
index 00000000000..7d942d969bb
--- /dev/null
+++ b/spec/frontend/deploy_keys/components/key_spec.js
@@ -0,0 +1,161 @@
+import { mount } from '@vue/test-utils';
+import DeployKeysStore from '~/deploy_keys/store';
+import key from '~/deploy_keys/components/key.vue';
+import { getTimeago } from '~/lib/utils/datetime_utility';
+
+describe('Deploy keys key', () => {
+ let wrapper;
+ let store;
+
+ const data = getJSONFixture('deploy_keys/keys.json');
+
+ const findTextAndTrim = selector =>
+ wrapper
+ .find(selector)
+ .text()
+ .trim();
+
+ const createComponent = propsData => {
+ wrapper = mount(key, {
+ propsData: {
+ store,
+ endpoint: 'https://test.host/dummy/endpoint',
+ ...propsData,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ store = new DeployKeysStore();
+ store.keys = data;
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('enabled key', () => {
+ const deployKey = data.enabled_keys[0];
+
+ it('renders the keys title', () => {
+ createComponent({ deployKey });
+
+ expect(findTextAndTrim('.title')).toContain('My title');
+ });
+
+ it('renders human friendly formatted created date', () => {
+ createComponent({ deployKey });
+
+ expect(findTextAndTrim('.key-created-at')).toBe(
+ `${getTimeago().format(deployKey.created_at)}`,
+ );
+ });
+
+ it('shows pencil button for editing', () => {
+ createComponent({ deployKey });
+
+ expect(wrapper.find('.btn .ic-pencil')).toExist();
+ });
+
+ it('shows disable button when the project is not deletable', () => {
+ createComponent({ deployKey });
+
+ expect(wrapper.find('.btn .ic-cancel')).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();
+ });
+ });
+
+ describe('deploy key labels', () => {
+ const deployKey = data.enabled_keys[0];
+ const deployKeysProjects = [...deployKey.deploy_keys_projects];
+ it('shows write access title when key has write access', () => {
+ deployKeysProjects[0] = { ...deployKeysProjects[0], can_push: true };
+ createComponent({ deployKey: { ...deployKey, deploy_keys_projects: deployKeysProjects } });
+
+ expect(wrapper.find('.deploy-project-label').attributes('data-original-title')).toBe(
+ 'Write access allowed',
+ );
+ });
+
+ it('does not show write access title when key has write access', () => {
+ deployKeysProjects[0] = { ...deployKeysProjects[0], can_push: false };
+ createComponent({ deployKey: { ...deployKey, deploy_keys_projects: deployKeysProjects } });
+
+ expect(wrapper.find('.deploy-project-label').attributes('data-original-title')).toBe(
+ 'Read access only',
+ );
+ });
+
+ it('shows expandable button if more than two projects', () => {
+ createComponent({ deployKey });
+ const labels = wrapper.findAll('.deploy-project-label');
+
+ expect(labels.length).toBe(2);
+ expect(labels.at(1).text()).toContain('others');
+ expect(labels.at(1).attributes('data-original-title')).toContain('Expand');
+ });
+
+ it('expands all project labels after click', () => {
+ createComponent({ deployKey });
+ const { length } = deployKey.deploy_keys_projects;
+ wrapper
+ .findAll('.deploy-project-label')
+ .at(1)
+ .trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ const labels = wrapper.findAll('.deploy-project-label');
+
+ expect(labels.length).toBe(length);
+ expect(labels.at(1).text()).not.toContain(`+${length} others`);
+ expect(labels.at(1).attributes('data-original-title')).not.toContain('Expand');
+ });
+ });
+
+ it('shows two projects', () => {
+ createComponent({
+ deployKey: { ...deployKey, deploy_keys_projects: [...deployKeysProjects].slice(0, 2) },
+ });
+
+ const labels = wrapper.findAll('.deploy-project-label');
+
+ expect(labels.length).toBe(2);
+ expect(labels.at(1).text()).toContain(deployKey.deploy_keys_projects[1].project.full_name);
+ });
+ });
+
+ describe('public keys', () => {
+ const deployKey = data.public_keys[0];
+
+ it('renders deploy keys without any enabled projects', () => {
+ createComponent({ deployKey: { ...deployKey, deploy_keys_projects: [] } });
+
+ expect(findTextAndTrim('.deploy-project-list')).toBe('None');
+ });
+
+ it('shows enable button', () => {
+ createComponent({ deployKey });
+ expect(findTextAndTrim('.btn')).toBe('Enable');
+ });
+
+ it('shows pencil button for editing', () => {
+ createComponent({ deployKey });
+ expect(wrapper.find('.btn .ic-pencil')).toExist();
+ });
+
+ it('shows disable button when key is enabled', () => {
+ store.keys.enabled_keys.push(deployKey);
+
+ createComponent({ deployKey });
+
+ expect(wrapper.find('.btn .ic-cancel')).toExist();
+ });
+ });
+});
diff --git a/spec/frontend/deploy_keys/components/keys_panel_spec.js b/spec/frontend/deploy_keys/components/keys_panel_spec.js
new file mode 100644
index 00000000000..53c8ba073bc
--- /dev/null
+++ b/spec/frontend/deploy_keys/components/keys_panel_spec.js
@@ -0,0 +1,63 @@
+import { mount } from '@vue/test-utils';
+import DeployKeysStore from '~/deploy_keys/store';
+import deployKeysPanel from '~/deploy_keys/components/keys_panel.vue';
+
+describe('Deploy keys panel', () => {
+ const data = getJSONFixture('deploy_keys/keys.json');
+ let wrapper;
+
+ const findTableRowHeader = () => wrapper.find('.table-row-header');
+
+ const mountComponent = props => {
+ const store = new DeployKeysStore();
+ store.keys = data;
+ wrapper = mount(deployKeysPanel, {
+ propsData: {
+ title: 'test',
+ keys: data.enabled_keys,
+ showHelpBox: true,
+ store,
+ endpoint: 'https://test.host/dummy/endpoint',
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders list of keys', () => {
+ mountComponent();
+ expect(wrapper.findAll('.deploy-key').length).toBe(wrapper.vm.keys.length);
+ });
+
+ it('renders table header', () => {
+ mountComponent();
+ const tableHeader = findTableRowHeader();
+
+ expect(tableHeader).toExist();
+ expect(tableHeader.text()).toContain('Deploy key');
+ expect(tableHeader.text()).toContain('Project usage');
+ expect(tableHeader.text()).toContain('Created');
+ });
+
+ it('renders help box if keys are empty', () => {
+ mountComponent({ keys: [] });
+
+ expect(wrapper.find('.settings-message').exists()).toBe(true);
+
+ expect(
+ wrapper
+ .find('.settings-message')
+ .text()
+ .trim(),
+ ).toBe('No deploy keys found. Create one with the form above.');
+ });
+
+ it('renders no table header if keys are empty', () => {
+ mountComponent({ keys: [] });
+ expect(findTableRowHeader().exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap
new file mode 100644
index 00000000000..4828e8cb3c2
--- /dev/null
+++ b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap
@@ -0,0 +1,42 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design discussions component should match the snapshot of note when repositioning 1`] = `
+<button
+ aria-label="Comment form position"
+ class="position-absolute btn-transparent comment-indicator"
+ style="left: 10px; top: 10px; cursor: move;"
+ type="button"
+>
+ <icon-stub
+ name="image-comment-dark"
+ size="16"
+ />
+</button>
+`;
+
+exports[`Design discussions component should match the snapshot of note with index 1`] = `
+<button
+ aria-label="Comment '1' position"
+ class="position-absolute js-image-badge badge badge-pill"
+ style="left: 10px; top: 10px;"
+ type="button"
+>
+
+ 1
+
+</button>
+`;
+
+exports[`Design discussions component should match the snapshot of note without index 1`] = `
+<button
+ aria-label="Comment form position"
+ class="position-absolute btn-transparent comment-indicator"
+ style="left: 10px; top: 10px;"
+ type="button"
+>
+ <icon-stub
+ name="image-comment-dark"
+ size="16"
+ />
+</button>
+`;
diff --git a/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap
new file mode 100644
index 00000000000..189962c5b2e
--- /dev/null
+++ b/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap
@@ -0,0 +1,104 @@
+// 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/components/__snapshots__/design_scaler_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap
new file mode 100644
index 00000000000..cb4575cbd11
--- /dev/null
+++ b/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap
@@ -0,0 +1,115 @@
+// 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/components/__snapshots__/image_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap
new file mode 100644
index 00000000000..acaa62b11eb
--- /dev/null
+++ b/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap
@@ -0,0 +1,68 @@
+// 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/components/delete_button_spec.js b/spec/frontend/design_management/components/delete_button_spec.js
new file mode 100644
index 00000000000..9d3bcd98e44
--- /dev/null
+++ b/spec/frontend/design_management/components/delete_button_spec.js
@@ -0,0 +1,51 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui';
+import BatchDeleteButton from '~/design_management/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/components/design_note_pin_spec.js b/spec/frontend/design_management/components/design_note_pin_spec.js
new file mode 100644
index 00000000000..4f7260b1363
--- /dev/null
+++ b/spec/frontend/design_management/components/design_note_pin_spec.js
@@ -0,0 +1,49 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignNotePin from '~/design_management/components/design_note_pin.vue';
+
+describe('Design discussions 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/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
new file mode 100644
index 00000000000..e071274cc81
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
@@ -0,0 +1,61 @@
+// 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>
+
+ <div
+ class="note-text js-note-text md"
+ data-qa-selector="note_content"
+ />
+</timeline-entry-item-stub>
+`;
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
new file mode 100644
index 00000000000..e01c79e3520
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
@@ -0,0 +1,15 @@
+// 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/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
new file mode 100644
index 00000000000..b16b26ff82f
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
@@ -0,0 +1,133 @@
+import { shallowMount } from '@vue/test-utils';
+import { ApolloMutation } from 'vue-apollo';
+import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
+import DesignNote from '~/design_management/components/design_notes/design_note.vue';
+import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
+import createNoteMutation from '~/design_management/graphql/mutations/createNote.mutation.graphql';
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+
+describe('Design discussions component', () => {
+ let wrapper;
+
+ const findReplyPlaceholder = () => wrapper.find(ReplyPlaceholder);
+ const findReplyForm = () => wrapper.find(DesignReplyForm);
+
+ 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 = shallowMount(DesignDiscussion, {
+ propsData: {
+ discussion: {
+ id: '0',
+ notes: [
+ {
+ id: '1',
+ },
+ {
+ id: '2',
+ },
+ ],
+ },
+ noteableId: 'noteable-id',
+ designId: 'design-id',
+ discussionIndex: 1,
+ ...props,
+ },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ stubs: {
+ ReplyPlaceholder,
+ ApolloMutation,
+ },
+ mocks: { $apollo },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders correct amount of discussion notes', () => {
+ createComponent();
+ expect(wrapper.findAll(DesignNote)).toHaveLength(2);
+ });
+
+ it('renders reply placeholder by default', () => {
+ createComponent();
+ expect(findReplyPlaceholder().exists()).toBe(true);
+ });
+
+ it('hides reply placeholder and opens form on placeholder click', () => {
+ createComponent();
+ findReplyPlaceholder().trigger('click');
+
+ 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({}, { 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({}, { 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: '1',
+ source: 'pin',
+ },
+ },
+ );
+
+ expect(wrapper.findAll(DesignNote).wrappers.every(note => note.classes('gl-bg-blue-50'))).toBe(
+ true,
+ );
+ });
+});
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
new file mode 100644
index 00000000000..8b32d3022ee
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js
@@ -0,0 +1,170 @@
+import { shallowMount } from '@vue/test-utils';
+import { ApolloMutation } from 'vue-apollo';
+import DesignNote from '~/design_management/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/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/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
new file mode 100644
index 00000000000..34b8f1f9fa8
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
@@ -0,0 +1,182 @@
+import { mount } from '@vue/test-utils';
+import DesignReplyForm from '~/design_management/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 = {}) {
+ wrapper = mount(DesignReplyForm, {
+ propsData: {
+ value: '',
+ isSaving: false,
+ ...props,
+ },
+ stubs: { GlModal },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('textarea has focus after component mount', () => {
+ createComponent();
+
+ 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/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js
new file mode 100644
index 00000000000..1c9b130aca6
--- /dev/null
+++ b/spec/frontend/design_management/components/design_overlay_spec.js
@@ -0,0 +1,393 @@
+import { mount } from '@vue/test-utils';
+import DesignOverlay from '~/design_management/components/design_overlay.vue';
+import updateActiveDiscussion from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql';
+import notes from '../mock_data/notes';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '~/design_management/constants';
+
+const mutate = jest.fn(() => Promise.resolve());
+
+describe('Design overlay component', () => {
+ let wrapper;
+
+ const mockDimensions = { width: 100, height: 100 };
+ const mockNoteNotAuthorised = {
+ id: 'note-not-authorised',
+ discussion: { id: 'discussion-not-authorised' },
+ position: {
+ x: 1,
+ y: 80,
+ ...mockDimensions,
+ },
+ userPermissions: {},
+ };
+
+ 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',
+ },
+ ...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', () => {
+ beforeEach(() => {
+ createComponent({
+ notes,
+ });
+ });
+
+ it('should render a correct amount of notes', () => {
+ expect(findAllNotes()).toHaveLength(notes.length);
+ });
+
+ it('should have a correct style for each note badge', () => {
+ expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;');
+ expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;');
+ });
+
+ 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);
+ });
+ });
+
+ 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');
+ });
+ });
+ });
+
+ 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,
+ },
+ ],
+ ]);
+ });
+ });
+
+ it('should do nothing if [adminNote] permission is not present', () => {
+ createComponent({
+ dimensions: mockDimensions,
+ notes: [mockNoteNotAuthorised],
+ });
+
+ const badge = findAllNotes().at(0);
+ return clickAndDragBadge(
+ badge,
+ { x: mockNoteNotAuthorised.x, y: mockNoteNotAuthorised.y },
+ { x: 20, y: 20 },
+ ).then(() => {
+ expect(wrapper.vm.movingNoteStartPosition).toBeNull();
+ expect(findFirstBadge().attributes().style).toBe('left: 1px; top: 80px;');
+ });
+ });
+ });
+
+ 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/components/design_presentation_spec.js b/spec/frontend/design_management/components/design_presentation_spec.js
new file mode 100644
index 00000000000..8a709393d92
--- /dev/null
+++ b/spec/frontend/design_management/components/design_presentation_spec.js
@@ -0,0 +1,546 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignPresentation from '~/design_management/components/design_presentation.vue';
+import DesignOverlay from '~/design_management/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 } = {},
+ data = {},
+ stubs = {},
+ ) {
+ wrapper = shallowMount(DesignPresentation, {
+ propsData: {
+ image,
+ imageName,
+ discussions,
+ isAnnotating,
+ },
+ 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/components/design_scaler_spec.js b/spec/frontend/design_management/components/design_scaler_spec.js
new file mode 100644
index 00000000000..b06d2f924df
--- /dev/null
+++ b/spec/frontend/design_management/components/design_scaler_spec.js
@@ -0,0 +1,67 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignScaler from '~/design_management/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/components/image_spec.js b/spec/frontend/design_management/components/image_spec.js
new file mode 100644
index 00000000000..52d60b04a8a
--- /dev/null
+++ b/spec/frontend/design_management/components/image_spec.js
@@ -0,0 +1,133 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import DesignImage from '~/design_management/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/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
new file mode 100644
index 00000000000..9cd427f6aae
--- /dev/null
+++ b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
@@ -0,0 +1,472 @@
+// 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 no notes renders item with correct status icon for creation event 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"
+ >
+ <div
+ class="design-event position-absolute"
+ >
+ <span
+ aria-label="Added in this version"
+ title="Added in this version"
+ >
+ <icon-stub
+ class="text-success-500"
+ name="file-addition-solid"
+ size="18"
+ />
+ </span>
+ </div>
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <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>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with no notes renders item with correct status icon for deletion event 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"
+ >
+ <div
+ class="design-event position-absolute"
+ >
+ <span
+ aria-label="Deleted in this version"
+ title="Deleted in this version"
+ >
+ <icon-stub
+ class="text-danger-500"
+ name="file-deletion-solid"
+ size="18"
+ />
+ </span>
+ </div>
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <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>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with no notes renders item with correct status icon for modification event 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"
+ >
+ <div
+ class="design-event position-absolute"
+ >
+ <span
+ aria-label="Modified in this version"
+ title="Modified in this version"
+ >
+ <icon-stub
+ class="text-primary-500"
+ name="file-modified-solid"
+ size="18"
+ />
+ </span>
+ </div>
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <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>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with no notes renders item with no status icon for none event 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
+ options="[object Object]"
+ >
+ <!---->
+
+ <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>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with no notes renders loading spinner when isUploading is true 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
+ options="[object Object]"
+ >
+ <gl-loading-icon-stub
+ color="orange"
+ label="Loading"
+ size="md"
+ />
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ style="display: none;"
+ />
+ </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>
+</router-link-stub>
+`;
+
+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
+ options="[object Object]"
+ >
+ <!---->
+
+ <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
+ options="[object Object]"
+ >
+ <!---->
+
+ <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/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js
new file mode 100644
index 00000000000..705b532454f
--- /dev/null
+++ b/spec/frontend/design_management/components/list/item_spec.js
@@ -0,0 +1,168 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlIcon, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
+import VueRouter from 'vue-router';
+import Item from '~/design_management/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;
+
+ 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();
+ });
+ });
+
+ describe('with no notes', () => {
+ it('renders item with no status icon for none event', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders item with correct status icon for modification event', () => {
+ createComponent({ event: DESIGN_VERSION_EVENT.MODIFICATION });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders item with correct status icon for deletion event', () => {
+ createComponent({ event: DESIGN_VERSION_EVENT.DELETION });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders item with correct status icon for creation event', () => {
+ createComponent({ event: DESIGN_VERSION_EVENT.CREATION });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders loading spinner when isUploading is true', () => {
+ createComponent({ isUploading: true });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap
new file mode 100644
index 00000000000..e55cff8de3d
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap
@@ -0,0 +1,61 @@
+// 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/components/toolbar/__snapshots__/pagination_button_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap
new file mode 100644
index 00000000000..08662a04f15
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap
@@ -0,0 +1,28 @@
+// 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/components/toolbar/__snapshots__/pagination_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap
new file mode 100644
index 00000000000..0197b4bff79
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap
@@ -0,0 +1,29 @@
+// 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/components/toolbar/index_spec.js b/spec/frontend/design_management/components/toolbar/index_spec.js
new file mode 100644
index 00000000000..2910b2f62ba
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/index_spec.js
@@ -0,0 +1,123 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import VueRouter from 'vue-router';
+import Toolbar from '~/design_management/components/toolbar/index.vue';
+import DeleteButton from '~/design_management/components/delete_button.vue';
+import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
+import { GlDeprecatedButton } from '@gitlab/ui';
+
+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/components/toolbar/pagination_button_spec.js b/spec/frontend/design_management/components/toolbar/pagination_button_spec.js
new file mode 100644
index 00000000000..b7df201795b
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/pagination_button_spec.js
@@ -0,0 +1,61 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import VueRouter from 'vue-router';
+import PaginationButton from '~/design_management/components/toolbar/pagination_button.vue';
+import { DESIGN_ROUTE_NAME } from '~/design_management/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/components/toolbar/pagination_spec.js b/spec/frontend/design_management/components/toolbar/pagination_spec.js
new file mode 100644
index 00000000000..db5a36dadf6
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/pagination_spec.js
@@ -0,0 +1,79 @@
+/* global Mousetrap */
+import 'mousetrap';
+import { shallowMount } from '@vue/test-utils';
+import Pagination from '~/design_management/components/toolbar/pagination.vue';
+import { DESIGN_ROUTE_NAME } from '~/design_management/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/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
new file mode 100644
index 00000000000..185bf4a48f7
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
@@ -0,0 +1,79 @@
+// 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"
+ >
+
+ Add 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"
+ >
+
+ Add 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"
+ >
+
+ Add 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/components/upload/__snapshots__/design_dropzone_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap
new file mode 100644
index 00000000000..0737b9729a2
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap
@@ -0,0 +1,455 @@
+// 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/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
new file mode 100644
index 00000000000..00f1a40dfb2
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
@@ -0,0 +1,111 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management design version dropdown component renders design version dropdown button 1`] = `
+<gl-dropdown-stub
+ class="design-version-dropdown"
+ issueiid=""
+ projectpath=""
+ text="Showing Latest Version"
+ variant="link"
+>
+ <gl-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 pull-right"
+ />
+ </router-link-stub>
+ </gl-dropdown-item-stub>
+ <gl-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-dropdown-item-stub>
+</gl-dropdown-stub>
+`;
+
+exports[`Design management design version dropdown component renders design version list 1`] = `
+<gl-dropdown-stub
+ class="design-version-dropdown"
+ issueiid=""
+ projectpath=""
+ text="Showing Latest Version"
+ variant="link"
+>
+ <gl-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 pull-right"
+ />
+ </router-link-stub>
+ </gl-dropdown-item-stub>
+ <gl-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-dropdown-item-stub>
+</gl-dropdown-stub>
+`;
diff --git a/spec/frontend/design_management/components/upload/button_spec.js b/spec/frontend/design_management/components/upload/button_spec.js
new file mode 100644
index 00000000000..c0a9693dc37
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/button_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import UploadButton from '~/design_management/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/components/upload/design_dropzone_spec.js b/spec/frontend/design_management/components/upload/design_dropzone_spec.js
new file mode 100644
index 00000000000..9b86b5b2878
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/design_dropzone_spec.js
@@ -0,0 +1,132 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue';
+import 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/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js
new file mode 100644
index 00000000000..7521b9fad2a
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js
@@ -0,0 +1,114 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+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(GlDropdown).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(GlDropdown).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(GlDropdown).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(GlDropdown).attributes('text')).toBe('Showing Latest Version');
+ });
+ });
+
+ it('should have the same length as apollo query', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.findAll(GlDropdownItem)).toHaveLength(wrapper.vm.allVersions.length);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/upload/mock_data/all_versions.js b/spec/frontend/design_management/components/upload/mock_data/all_versions.js
new file mode 100644
index 00000000000..e76bbd261bd
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/mock_data/all_versions.js
@@ -0,0 +1,14 @@
+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/mock_data/all_versions.js b/spec/frontend/design_management/mock_data/all_versions.js
new file mode 100644
index 00000000000..c389fdb8747
--- /dev/null
+++ b/spec/frontend/design_management/mock_data/all_versions.js
@@ -0,0 +1,8 @@
+export default [
+ {
+ node: {
+ id: 'gid://gitlab/DesignManagement::Version/1',
+ sha: 'b389071a06c153509e11da1f582005b316667001',
+ },
+ },
+];
diff --git a/spec/frontend/design_management/mock_data/design.js b/spec/frontend/design_management/mock_data/design.js
new file mode 100644
index 00000000000..34e3077f4a2
--- /dev/null
+++ b/spec/frontend/design_management/mock_data/design.js
@@ -0,0 +1,54 @@
+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',
+ notes: {
+ nodes: [
+ {
+ id: 'note-id',
+ 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/mock_data/designs.js b/spec/frontend/design_management/mock_data/designs.js
new file mode 100644
index 00000000000..07f5c1b7457
--- /dev/null
+++ b/spec/frontend/design_management/mock_data/designs.js
@@ -0,0 +1,17 @@
+import design from './design';
+
+export default {
+ project: {
+ issue: {
+ designCollection: {
+ designs: {
+ edges: [
+ {
+ node: design,
+ },
+ ],
+ },
+ },
+ },
+ },
+};
diff --git a/spec/frontend/design_management/mock_data/no_designs.js b/spec/frontend/design_management/mock_data/no_designs.js
new file mode 100644
index 00000000000..9db0ffcade2
--- /dev/null
+++ b/spec/frontend/design_management/mock_data/no_designs.js
@@ -0,0 +1,11 @@
+export default {
+ project: {
+ issue: {
+ designCollection: {
+ designs: {
+ edges: [],
+ },
+ },
+ },
+ },
+};
diff --git a/spec/frontend/design_management/mock_data/notes.js b/spec/frontend/design_management/mock_data/notes.js
new file mode 100644
index 00000000000..db4624c8524
--- /dev/null
+++ b/spec/frontend/design_management/mock_data/notes.js
@@ -0,0 +1,32 @@
+export default [
+ {
+ id: 'note-id-1',
+ position: {
+ height: 100,
+ width: 100,
+ x: 10,
+ y: 15,
+ },
+ userPermissions: {
+ adminNote: true,
+ },
+ discussion: {
+ id: 'discussion-id-1',
+ },
+ },
+ {
+ id: 'note-id-2',
+ position: {
+ height: 50,
+ width: 50,
+ x: 25,
+ y: 25,
+ },
+ userPermissions: {
+ adminNote: true,
+ },
+ discussion: {
+ id: 'discussion-id-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
new file mode 100644
index 00000000000..3ba63fd14f0
--- /dev/null
+++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
@@ -0,0 +1,263 @@
+// 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/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
new file mode 100644
index 00000000000..76e481ee518
--- /dev/null
+++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
@@ -0,0 +1,184 @@
+// 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]"
+ 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-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0"
+ >
+
+ My precious issue
+
+ </h2>
+
+ <a
+ class="text-tertiary text-decoration-none mb-3 d-block"
+ href="full-issue-url"
+ >
+ ull-issue-path
+ </a>
+
+ <participants-stub
+ class="mb-4"
+ numberoflessparticipants="7"
+ participants="[object Object]"
+ />
+
+ <div
+ class="design-discussion-wrapper"
+ >
+ <div
+ class="badge badge-pill"
+ type="button"
+ >
+ 1
+ </div>
+
+ <div
+ class="design-discussion bordered-box position-relative"
+ data-qa-selector="design_discussion_content"
+ >
+ <design-note-stub
+ class=""
+ markdownpreviewpath="//preview_markdown?target_type=Issue"
+ note="[object Object]"
+ />
+
+ <div
+ class="reply-wrapper"
+ >
+ <reply-placeholder-stub
+ buttontext="Reply..."
+ class="qa-discussion-reply"
+ />
+ </div>
+ </div>
+ </div>
+
+ <!---->
+ </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-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0"
+ >
+
+ My precious issue
+
+ </h2>
+
+ <a
+ class="text-tertiary text-decoration-none mb-3 d-block"
+ href="full-issue-url"
+ >
+ ull-issue-path
+ </a>
+
+ <participants-stub
+ class="mb-4"
+ numberoflessparticipants="7"
+ participants="[object Object]"
+ />
+
+ <h2
+ class="new-discussion-disclaimer gl-font-base m-0"
+ >
+
+ Click the image where you'd like to start a new discussion
+
+ </h2>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js
new file mode 100644
index 00000000000..9e2f071a983
--- /dev/null
+++ b/spec/frontend/design_management/pages/design/index_spec.js
@@ -0,0 +1,301 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlAlert } from '@gitlab/ui';
+import { ApolloMutation } from 'vue-apollo';
+import createFlash from '~/flash';
+import DesignIndex from '~/design_management/pages/design/index.vue';
+import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
+import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
+import Participants from '~/sidebar/components/participants/participants.vue';
+import createImageDiffNoteMutation from '~/design_management/graphql/mutations/createImageDiffNote.mutation.graphql';
+import updateActiveDiscussionMutation from '~/design_management/graphql/mutations/update_active_discussion.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/utils/error_messages';
+import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
+
+jest.mock('~/flash');
+jest.mock('mousetrap', () => ({
+ bind: jest.fn(),
+ unbind: jest.fn(),
+}));
+
+describe('Design management design index page', () => {
+ let wrapper;
+ 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 updateActiveDiscussionMutationVariables = {
+ mutation: updateActiveDiscussionMutation,
+ variables: {
+ id: design.discussions.nodes[0].notes.nodes[0].id,
+ source: 'discussion',
+ },
+ };
+
+ const mutate = jest.fn().mockResolvedValue();
+ const routerPush = jest.fn();
+
+ const findDiscussions = () => wrapper.findAll(DesignDiscussion);
+ const findDiscussionForm = () => wrapper.find(DesignReplyForm);
+ const findParticipants = () => wrapper.find(Participants);
+ const findDiscussionsWrapper = () => wrapper.find('.image-notes');
+
+ function createComponent(loading = false, data = {}, { routeQuery = {} } = {}) {
+ const $apollo = {
+ queries: {
+ design: {
+ loading,
+ },
+ },
+ mutate,
+ };
+
+ const $router = {
+ push: routerPush,
+ };
+
+ const $route = {
+ query: routeQuery,
+ };
+
+ wrapper = shallowMount(DesignIndex, {
+ propsData: { id: '1' },
+ mocks: { $apollo, $router, $route },
+ stubs: {
+ ApolloMutation,
+ DesignDiscussion,
+ },
+ data() {
+ return {
+ issueIid: '1',
+ activeDiscussion: {
+ id: null,
+ source: null,
+ },
+ ...data,
+ };
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ 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('renders participants', () => {
+ createComponent(false, { design });
+
+ expect(findParticipants().exists()).toBe(true);
+ });
+
+ it('passes the correct amount of participants to the Participants component', () => {
+ createComponent(false, { design });
+
+ expect(findParticipants().props('participants')).toHaveLength(1);
+ });
+
+ describe('when has no discussions', () => {
+ beforeEach(() => {
+ createComponent(false, {
+ 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(wrapper.find('.new-discussion-disclaimer').exists()).toBe(true);
+ });
+ });
+
+ describe('when has discussions', () => {
+ beforeEach(() => {
+ createComponent(false, { design });
+ });
+
+ it('renders correct amount of discussions', () => {
+ expect(findDiscussions()).toHaveLength(1);
+ });
+
+ it('sends a mutation to set an active discussion when clicking on a discussion', () => {
+ findDiscussions()
+ .at(0)
+ .trigger('click');
+
+ expect(mutate).toHaveBeenCalledWith(updateActiveDiscussionMutationVariables);
+ });
+
+ it('sends a mutation to reset an active discussion when clicking outside of discussion', () => {
+ findDiscussionsWrapper().trigger('click');
+
+ expect(mutate).toHaveBeenCalledWith({
+ ...updateActiveDiscussionMutationVariables,
+ variables: { id: undefined, source: 'discussion' },
+ });
+ });
+ });
+
+ it('opens a new discussion form', () => {
+ createComponent(false, {
+ design: {
+ ...design,
+ discussions: {
+ nodes: [],
+ },
+ },
+ });
+
+ wrapper.vm.openCommentForm({ x: 0, y: 0 });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findDiscussionForm().exists()).toBe(true);
+ });
+ });
+
+ 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);
+
+ wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(DESIGN_NOT_FOUND_ERROR);
+ expect(routerPush).toHaveBeenCalledTimes(1);
+ expect(routerPush).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME });
+ });
+ });
+ });
+
+ describe('when no design exists for given version', () => {
+ it('redirects to /designs', () => {
+ // attempt to query for a version of the design that doesn't exist
+ createComponent(true, {}, { routeQuery: { version: '999' } });
+ wrapper.setData({
+ allVersions: mockAllVersions,
+ });
+
+ 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(routerPush).toHaveBeenCalledTimes(1);
+ expect(routerPush).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js
new file mode 100644
index 00000000000..2299b858da9
--- /dev/null
+++ b/spec/frontend/design_management/pages/index_spec.js
@@ -0,0 +1,533 @@
+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/pages/index.vue';
+import uploadDesignQuery from '~/design_management/graphql/mutations/uploadDesign.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 { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
+import {
+ EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
+ EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
+} from '~/design_management/utils/error_messages';
+import createFlash from '~/flash';
+
+const localVue = createLocalVue();
+localVue.use(VueRouter);
+const router = new VueRouter({
+ routes: [
+ {
+ name: DESIGNS_ROUTE_NAME,
+ path: '/designs',
+ component: Index,
+ },
+ ],
+});
+
+jest.mock('~/flash.js');
+
+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();
+ });
+ });
+});
diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js
new file mode 100644
index 00000000000..0f4afa5e288
--- /dev/null
+++ b/spec/frontend/design_management/router_spec.js
@@ -0,0 +1,81 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import VueRouter from 'vue-router';
+import App from '~/design_management/components/app.vue';
+import Designs from '~/design_management/pages/index.vue';
+import DesignDetail from '~/design_management/pages/design/index.vue';
+import createRouter from '~/design_management/router';
+import {
+ ROOT_ROUTE_NAME,
+ DESIGNS_ROUTE_NAME,
+ DESIGN_ROUTE_NAME,
+} from '~/design_management/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 },
+ },
+ },
+ },
+ });
+}
+
+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/utils/cache_update_spec.js b/spec/frontend/design_management/utils/cache_update_spec.js
new file mode 100644
index 00000000000..641d35ff9ff
--- /dev/null
+++ b/spec/frontend/design_management/utils/cache_update_spec.js
@@ -0,0 +1,44 @@
+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';
+import design from '../mock_data/design';
+import 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/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js
new file mode 100644
index 00000000000..af631073df6
--- /dev/null
+++ b/spec/frontend/design_management/utils/design_management_utils_spec.js
@@ -0,0 +1,176 @@
+import {
+ extractCurrentDiscussion,
+ extractDiscussions,
+ findVersionId,
+ designUploadOptimisticResponse,
+ updateImageDiffNoteOptimisticResponse,
+ isValidDesignFile,
+ extractDesign,
+} from '~/design_management/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'] },
+ { id: 2, notes: ['b'] },
+ { id: 3, notes: ['c'] },
+ { id: 4, notes: ['d'] },
+ ]);
+ });
+});
+
+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/utils/error_messages_spec.js b/spec/frontend/design_management/utils/error_messages_spec.js
new file mode 100644
index 00000000000..635ff931d7d
--- /dev/null
+++ b/spec/frontend/design_management/utils/error_messages_spec.js
@@ -0,0 +1,62 @@
+import {
+ designDeletionError,
+ designUploadSkippedWarning,
+} from '~/design_management/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) => {
+ test('returns expected warning message', () => {
+ expect(designUploadSkippedWarning(uploadedFiles, skippedFiles)).toBe(expected);
+ });
+ });
+});
diff --git a/spec/frontend/design_management/utils/tracking_spec.js b/spec/frontend/design_management/utils/tracking_spec.js
new file mode 100644
index 00000000000..9fa5eae55b3
--- /dev/null
+++ b/spec/frontend/design_management/utils/tracking_spec.js
@@ -0,0 +1,53 @@
+import { mockTracking } from 'helpers/tracking_helper';
+import { trackDesignDetailView } from '~/design_management/utils/tracking';
+
+function getTrackingSpy(key) {
+ return mockTracking(key, undefined, jest.spyOn);
+}
+
+describe('Tracking Events', () => {
+ describe('trackDesignDetailView', () => {
+ const eventKey = 'projects:issues:design';
+ const eventName = 'design_viewed';
+
+ it('trackDesignDetailView fires a tracking event when called', () => {
+ const trackingSpy = getTrackingSpy(eventKey);
+
+ trackDesignDetailView();
+
+ expect(trackingSpy).toHaveBeenCalledWith(
+ eventKey,
+ eventName,
+ expect.objectContaining({
+ label: eventName,
+ value: {
+ 'internal-object-refrerer': '',
+ 'design-collection-owner': '',
+ 'design-version-number': 1,
+ 'design-is-current-version': false,
+ },
+ }),
+ );
+ });
+
+ 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,
+ value: {
+ 'internal-object-refrerer': 'from-a-test',
+ 'design-collection-owner': 'test',
+ 'design-version-number': 100,
+ 'design-is-current-version': true,
+ },
+ }),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/diff_comments_store_spec.js b/spec/frontend/diff_comments_store_spec.js
new file mode 100644
index 00000000000..6f25c9dd3bc
--- /dev/null
+++ b/spec/frontend/diff_comments_store_spec.js
@@ -0,0 +1,136 @@
+/* global CommentsStore */
+
+import '~/diff_notes/models/discussion';
+import '~/diff_notes/models/note';
+import '~/diff_notes/stores/comments';
+
+function createDiscussion(noteId = 1, resolved = true) {
+ CommentsStore.create({
+ discussionId: 'a',
+ noteId,
+ canResolve: true,
+ resolved,
+ resolvedBy: 'test',
+ authorName: 'test',
+ authorAvatar: 'test',
+ noteTruncated: 'test...',
+ });
+}
+
+beforeEach(() => {
+ CommentsStore.state = {};
+});
+
+describe('New discussion', () => {
+ it('creates new discussion', () => {
+ expect(Object.keys(CommentsStore.state).length).toBe(0);
+ createDiscussion();
+
+ expect(Object.keys(CommentsStore.state).length).toBe(1);
+ });
+
+ it('creates new note in discussion', () => {
+ createDiscussion();
+ createDiscussion(2);
+
+ const discussion = CommentsStore.state.a;
+
+ expect(Object.keys(discussion.notes).length).toBe(2);
+ });
+});
+
+describe('Get note', () => {
+ beforeEach(() => {
+ createDiscussion();
+ });
+
+ it('gets note by ID', () => {
+ const note = CommentsStore.get('a', 1);
+
+ expect(note).toBeDefined();
+ expect(note.id).toBe(1);
+ });
+});
+
+describe('Delete discussion', () => {
+ beforeEach(() => {
+ createDiscussion();
+ });
+
+ it('deletes discussion by ID', () => {
+ CommentsStore.delete('a', 1);
+
+ expect(Object.keys(CommentsStore.state).length).toBe(0);
+ });
+
+ it('deletes discussion when no more notes', () => {
+ createDiscussion();
+ createDiscussion(2);
+
+ expect(Object.keys(CommentsStore.state).length).toBe(1);
+ expect(Object.keys(CommentsStore.state.a.notes).length).toBe(2);
+
+ CommentsStore.delete('a', 1);
+ CommentsStore.delete('a', 2);
+
+ expect(Object.keys(CommentsStore.state).length).toBe(0);
+ });
+});
+
+describe('Update note', () => {
+ beforeEach(() => {
+ createDiscussion();
+ });
+
+ it('updates note to be unresolved', () => {
+ CommentsStore.update('a', 1, false, 'test');
+
+ const note = CommentsStore.get('a', 1);
+
+ expect(note.resolved).toBe(false);
+ });
+});
+
+describe('Discussion resolved', () => {
+ beforeEach(() => {
+ createDiscussion();
+ });
+
+ it('is resolved with single note', () => {
+ const discussion = CommentsStore.state.a;
+
+ expect(discussion.isResolved()).toBe(true);
+ });
+
+ it('is unresolved with 2 notes', () => {
+ const discussion = CommentsStore.state.a;
+ createDiscussion(2, false);
+
+ expect(discussion.isResolved()).toBe(false);
+ });
+
+ it('is resolved with 2 notes', () => {
+ const discussion = CommentsStore.state.a;
+ createDiscussion(2);
+
+ expect(discussion.isResolved()).toBe(true);
+ });
+
+ it('resolve all notes', () => {
+ const discussion = CommentsStore.state.a;
+ createDiscussion(2, false);
+
+ discussion.resolveAllNotes();
+
+ expect(discussion.isResolved()).toBe(true);
+ });
+
+ it('unresolve all notes', () => {
+ const discussion = CommentsStore.state.a;
+ createDiscussion(2);
+
+ discussion.unResolveAllNotes();
+
+ expect(discussion.isResolved()).toBe(false);
+ });
+});
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index 3a0354205f8..57e3a93c6f4 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -14,10 +14,13 @@ import TreeList from '~/diffs/components/tree_list.vue';
import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '~/diffs/constants';
import createDiffsStore from '../create_diffs_store';
import axios from '~/lib/utils/axios_utils';
+import * as urlUtils from '~/lib/utils/url_utility';
import diffsMockData from '../mock_data/merge_request_diffs';
const mergeRequestDiff = { version_index: 1 };
const TEST_ENDPOINT = `${TEST_HOST}/diff/endpoint`;
+const COMMIT_URL = '[BASE URL]/OLD';
+const UPDATED_COMMIT_URL = '[BASE URL]/NEW';
describe('diffs/components/app', () => {
const oldMrTabs = window.mrTabs;
@@ -25,8 +28,14 @@ describe('diffs/components/app', () => {
let wrapper;
let mock;
- function createComponent(props = {}, extendStore = () => {}) {
+ function createComponent(props = {}, extendStore = () => {}, provisions = {}) {
const localVue = createLocalVue();
+ const provide = {
+ ...provisions,
+ glFeatures: {
+ ...(provisions.glFeatures || {}),
+ },
+ };
localVue.use(Vuex);
@@ -49,6 +58,7 @@ describe('diffs/components/app', () => {
showSuggestPopover: true,
...props,
},
+ provide,
store,
methods: {
isLatestVersion() {
@@ -79,7 +89,10 @@ describe('diffs/components/app', () => {
window.mrTabs = oldMrTabs;
// reset component
- wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
mock.restore();
});
@@ -452,76 +465,109 @@ describe('diffs/components/app', () => {
});
describe('keyboard shortcut navigation', () => {
- const mappings = {
- '[': -1,
- k: -1,
- ']': +1,
- j: +1,
- };
- let spy;
+ let spies = [];
+ let jumpSpy;
+ let moveSpy;
+
+ function setup(componentProps, featureFlags) {
+ createComponent(
+ componentProps,
+ ({ state }) => {
+ state.diffs.commit = { id: 'SHA123' };
+ },
+ { glFeatures: { mrCommitNeighborNav: true, ...featureFlags } },
+ );
+
+ moveSpy = jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {});
+ jumpSpy = jest.fn();
+ spies = [jumpSpy, moveSpy];
+ wrapper.setMethods({
+ jumpToFile: jumpSpy,
+ });
+ }
describe('visible app', () => {
- beforeEach(() => {
- spy = jest.fn();
+ it.each`
+ key | name | spy | args | featureFlags
+ ${'['} | ${'jumpToFile'} | ${0} | ${[-1]} | ${{}}
+ ${'k'} | ${'jumpToFile'} | ${0} | ${[-1]} | ${{}}
+ ${']'} | ${'jumpToFile'} | ${0} | ${[+1]} | ${{}}
+ ${'j'} | ${'jumpToFile'} | ${0} | ${[+1]} | ${{}}
+ ${'x'} | ${'moveToNeighboringCommit'} | ${1} | ${[{ direction: 'previous' }]} | ${{ mrCommitNeighborNav: true }}
+ ${'c'} | ${'moveToNeighboringCommit'} | ${1} | ${[{ direction: 'next' }]} | ${{ mrCommitNeighborNav: true }}
+ `(
+ 'calls `$name()` with correct parameters whenever the "$key" key is pressed',
+ ({ key, spy, args, featureFlags }) => {
+ setup({ shouldShow: true }, featureFlags);
- createComponent({
- shouldShow: true,
- });
- wrapper.setMethods({
- jumpToFile: spy,
- });
- });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(spies[spy]).not.toHaveBeenCalled();
+
+ Mousetrap.trigger(key);
+
+ expect(spies[spy]).toHaveBeenCalledWith(...args);
+ });
+ },
+ );
+
+ it.each`
+ key | name | spy | featureFlags
+ ${'x'} | ${'moveToNeighboringCommit'} | ${1} | ${{ mrCommitNeighborNav: false }}
+ ${'c'} | ${'moveToNeighboringCommit'} | ${1} | ${{ mrCommitNeighborNav: false }}
+ `(
+ 'does not call `$name()` even when the correct key is pressed if the feature flag is disabled',
+ ({ key, spy, featureFlags }) => {
+ setup({ shouldShow: true }, featureFlags);
- it.each(Object.keys(mappings))(
- 'calls `jumpToFile()` with correct parameter whenever pre-defined %s is pressed',
- key => {
return wrapper.vm.$nextTick().then(() => {
- expect(spy).not.toHaveBeenCalled();
+ expect(spies[spy]).not.toHaveBeenCalled();
Mousetrap.trigger(key);
- expect(spy).toHaveBeenCalledWith(mappings[key]);
+ expect(spies[spy]).not.toHaveBeenCalled();
});
},
);
- it('does not call `jumpToFile()` when unknown key is pressed', done => {
- wrapper.vm
- .$nextTick()
- .then(() => {
- Mousetrap.trigger('d');
+ it.each`
+ key | name | spy | allowed
+ ${'d'} | ${'jumpToFile'} | ${0} | ${['[', ']', 'j', 'k']}
+ ${'r'} | ${'moveToNeighboringCommit'} | ${1} | ${['x', 'c']}
+ `(
+ `does not call \`$name()\` when a key that is not one of \`$allowed\` is pressed`,
+ ({ key, spy }) => {
+ setup({ shouldShow: true }, { mrCommitNeighborNav: true });
- expect(spy).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
+ return wrapper.vm.$nextTick().then(() => {
+ Mousetrap.trigger(key);
+
+ expect(spies[spy]).not.toHaveBeenCalled();
+ });
+ },
+ );
});
- describe('hideen app', () => {
+ describe('hidden app', () => {
beforeEach(() => {
- spy = jest.fn();
+ setup({ shouldShow: false }, { mrCommitNeighborNav: true });
- createComponent({
- shouldShow: false,
- });
- wrapper.setMethods({
- jumpToFile: spy,
+ return wrapper.vm.$nextTick().then(() => {
+ Mousetrap.reset();
});
});
- it('stops calling `jumpToFile()` when application is hidden', done => {
- wrapper.vm
- .$nextTick()
- .then(() => {
- Object.keys(mappings).forEach(key => {
- Mousetrap.trigger(key);
+ it.each`
+ key | name | spy
+ ${'['} | ${'jumpToFile'} | ${0}
+ ${'k'} | ${'jumpToFile'} | ${0}
+ ${']'} | ${'jumpToFile'} | ${0}
+ ${'j'} | ${'jumpToFile'} | ${0}
+ ${'x'} | ${'moveToNeighboringCommit'} | ${1}
+ ${'c'} | ${'moveToNeighboringCommit'} | ${1}
+ `('stops calling `$name()` when the app is hidden', ({ key, spy }) => {
+ Mousetrap.trigger(key);
- expect(spy).not.toHaveBeenCalled();
- });
- })
- .then(done)
- .catch(done.fail);
+ expect(spies[spy]).not.toHaveBeenCalled();
});
});
});
@@ -602,6 +648,70 @@ describe('diffs/components/app', () => {
});
});
+ describe('commit watcher', () => {
+ const spy = () => {
+ jest.spyOn(wrapper.vm, 'refetchDiffData').mockImplementation(() => {});
+ jest.spyOn(wrapper.vm, 'adjustView').mockImplementation(() => {});
+ };
+ let location;
+
+ beforeAll(() => {
+ location = window.location;
+ delete window.location;
+ window.location = COMMIT_URL;
+ document.title = 'My Title';
+ });
+
+ beforeEach(() => {
+ jest.spyOn(urlUtils, 'updateHistory');
+ });
+
+ afterAll(() => {
+ window.location = location;
+ });
+
+ it('when the commit changes and the app is not loading it should update the history, refetch the diff data, and update the view', () => {
+ createComponent({}, ({ state }) => {
+ state.diffs.commit = { ...state.diffs.commit, id: 'OLD' };
+ });
+ spy();
+
+ store.state.diffs.commit = { id: 'NEW' };
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(urlUtils.updateHistory).toHaveBeenCalledWith({
+ title: document.title,
+ url: UPDATED_COMMIT_URL,
+ });
+ expect(wrapper.vm.refetchDiffData).toHaveBeenCalled();
+ expect(wrapper.vm.adjustView).toHaveBeenCalled();
+ });
+ });
+
+ it.each`
+ isLoading | oldSha | newSha
+ ${true} | ${'OLD'} | ${'NEW'}
+ ${false} | ${'NEW'} | ${'NEW'}
+ `(
+ 'given `{ "isLoading": $isLoading, "oldSha": "$oldSha", "newSha": "$newSha" }`, nothing should happen',
+ ({ isLoading, oldSha, newSha }) => {
+ createComponent({}, ({ state }) => {
+ state.diffs.isLoading = isLoading;
+ state.diffs.commit = { ...state.diffs.commit, id: oldSha };
+ });
+ spy();
+
+ store.state.diffs.commit = { id: newSha };
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(urlUtils.updateHistory).not.toHaveBeenCalled();
+ expect(wrapper.vm.refetchDiffData).not.toHaveBeenCalled();
+ expect(wrapper.vm.adjustView).not.toHaveBeenCalled();
+ });
+ },
+ );
+ });
+
describe('diffs', () => {
it('should render compare versions component', () => {
createComponent({}, ({ state }) => {
diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js
index 6bb3a0dcf21..0df951d43a7 100644
--- a/spec/frontend/diffs/components/commit_item_spec.js
+++ b/spec/frontend/diffs/components/commit_item_spec.js
@@ -13,6 +13,8 @@ const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com';
const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`;
const TEST_SIGNATURE_HTML = '<a>Legit commit</a>';
const TEST_PIPELINE_STATUS_PATH = `${TEST_HOST}/pipeline/status`;
+const NEXT_COMMIT_URL = `${TEST_HOST}/?commit_id=next`;
+const PREV_COMMIT_URL = `${TEST_HOST}/?commit_id=prev`;
describe('diffs/components/commit_item', () => {
let wrapper;
@@ -30,12 +32,24 @@ describe('diffs/components/commit_item', () => {
const getCommitActionsElement = () => wrapper.find('.commit-actions');
const getCommitPipelineStatus = () => wrapper.find(CommitPipelineStatus);
- const mountComponent = propsData => {
+ const getCommitNavButtonsElement = () => wrapper.find('.commit-nav-buttons');
+ const getNextCommitNavElement = () =>
+ getCommitNavButtonsElement().find('.btn-group > *:last-child');
+ const getPrevCommitNavElement = () =>
+ getCommitNavButtonsElement().find('.btn-group > *:first-child');
+
+ const mountComponent = (propsData, featureFlags = {}) => {
wrapper = mount(Component, {
propsData: {
commit,
...propsData,
},
+ provide: {
+ glFeatures: {
+ mrCommitNeighborNav: true,
+ ...featureFlags,
+ },
+ },
stubs: {
CommitPipelineStatus: true,
},
@@ -173,4 +187,132 @@ describe('diffs/components/commit_item', () => {
expect(getCommitPipelineStatus().exists()).toBe(true);
});
});
+
+ describe('without neighbor commits', () => {
+ beforeEach(() => {
+ mountComponent({ commit: { ...commit, prev_commit_id: null, next_commit_id: null } });
+ });
+
+ it('does not render any navigation buttons', () => {
+ expect(getCommitNavButtonsElement().exists()).toEqual(false);
+ });
+ });
+
+ describe('with neighbor commits', () => {
+ let mrCommit;
+
+ beforeEach(() => {
+ mrCommit = {
+ ...commit,
+ next_commit_id: 'next',
+ prev_commit_id: 'prev',
+ };
+
+ mountComponent({ commit: mrCommit });
+ });
+
+ it('renders the commit navigation buttons', () => {
+ expect(getCommitNavButtonsElement().exists()).toEqual(true);
+
+ mountComponent({
+ commit: { ...mrCommit, next_commit_id: null },
+ });
+ expect(getCommitNavButtonsElement().exists()).toEqual(true);
+
+ mountComponent({
+ commit: { ...mrCommit, prev_commit_id: null },
+ });
+ expect(getCommitNavButtonsElement().exists()).toEqual(true);
+ });
+
+ it('does not render the commit navigation buttons if the `mrCommitNeighborNav` feature flag is disabled', () => {
+ mountComponent({ commit: mrCommit }, { mrCommitNeighborNav: false });
+
+ expect(getCommitNavButtonsElement().exists()).toEqual(false);
+ });
+
+ describe('prev commit', () => {
+ const { location } = window;
+
+ beforeAll(() => {
+ delete window.location;
+ window.location = { href: `${TEST_HOST}?commit_id=${mrCommit.id}` };
+ });
+
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {});
+ });
+
+ afterAll(() => {
+ window.location = location;
+ });
+
+ it('uses the correct href', () => {
+ const link = getPrevCommitNavElement();
+
+ expect(link.element.getAttribute('href')).toEqual(PREV_COMMIT_URL);
+ });
+
+ it('triggers the correct Vuex action on click', () => {
+ const link = getPrevCommitNavElement();
+
+ link.trigger('click');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({
+ direction: 'previous',
+ });
+ });
+ });
+
+ it('renders a disabled button when there is no prev commit', () => {
+ mountComponent({ commit: { ...mrCommit, prev_commit_id: null } });
+
+ const button = getPrevCommitNavElement();
+
+ expect(button.element.tagName).toEqual('BUTTON');
+ expect(button.element.hasAttribute('disabled')).toEqual(true);
+ });
+ });
+
+ describe('next commit', () => {
+ const { location } = window;
+
+ beforeAll(() => {
+ delete window.location;
+ window.location = { href: `${TEST_HOST}?commit_id=${mrCommit.id}` };
+ });
+
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {});
+ });
+
+ afterAll(() => {
+ window.location = location;
+ });
+
+ it('uses the correct href', () => {
+ const link = getNextCommitNavElement();
+
+ expect(link.element.getAttribute('href')).toEqual(NEXT_COMMIT_URL);
+ });
+
+ it('triggers the correct Vuex action on click', () => {
+ const link = getNextCommitNavElement();
+
+ link.trigger('click');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({ direction: 'next' });
+ });
+ });
+
+ it('renders a disabled button when there is no next commit', () => {
+ mountComponent({ commit: { ...mrCommit, next_commit_id: null } });
+
+ const button = getNextCommitNavElement();
+
+ expect(button.element.tagName).toEqual('BUTTON');
+ expect(button.element.hasAttribute('disabled')).toEqual(true);
+ });
+ });
+ });
});
diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js
index 979c67787f7..b78895f9e55 100644
--- a/spec/frontend/diffs/components/diff_content_spec.js
+++ b/spec/frontend/diffs/components/diff_content_spec.js
@@ -10,7 +10,7 @@ import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
import NoteForm from '~/notes/components/note_form.vue';
import DiffDiscussions from '~/diffs/components/diff_discussions.vue';
import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
-import diffFileMockData from '../../../javascripts/diffs/mock_data/diff_file';
+import diffFileMockData from '../mock_data/diff_file';
import { diffViewerModes } from '~/ide/constants';
const localVue = createLocalVue();
diff --git a/spec/frontend/diffs/components/diff_discussions_spec.js b/spec/frontend/diffs/components/diff_discussions_spec.js
index ba5a4f96204..83becc7a20a 100644
--- a/spec/frontend/diffs/components/diff_discussions_spec.js
+++ b/spec/frontend/diffs/components/diff_discussions_spec.js
@@ -13,7 +13,7 @@ const localVue = createLocalVue();
describe('DiffDiscussions', () => {
let store;
let wrapper;
- const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)];
+ const getDiscussionsMockData = () => [{ ...discussionsMockData }];
const createComponent = props => {
store = createStore();
diff --git a/spec/frontend/diffs/components/diff_expansion_cell_spec.js b/spec/frontend/diffs/components/diff_expansion_cell_spec.js
index 31c6a4d5b60..0504f3933e0 100644
--- a/spec/frontend/diffs/components/diff_expansion_cell_spec.js
+++ b/spec/frontend/diffs/components/diff_expansion_cell_spec.js
@@ -81,7 +81,7 @@ describe('DiffExpansionCell', () => {
isTop: false,
isBottom: false,
};
- const props = Object.assign({}, defaults, options);
+ const props = { ...defaults, ...options };
vm = createComponentWithStore(cmp, store, props).$mount();
};
diff --git a/spec/frontend/diffs/components/diff_gutter_avatars_spec.js b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js
index 4d8345d494d..da18d8e7894 100644
--- a/spec/frontend/diffs/components/diff_gutter_avatars_spec.js
+++ b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js
@@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue';
import discussionsMockData from '../mock_data/diff_discussions';
-const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)];
+const getDiscussionsMockData = () => [{ ...discussionsMockData }];
describe('DiffGutterAvatars', () => {
let wrapper;
diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js
index 9b032d10fdc..3e0acd0dace 100644
--- a/spec/frontend/diffs/components/diff_line_note_form_spec.js
+++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js
@@ -9,7 +9,7 @@ describe('DiffLineNoteForm', () => {
let wrapper;
let diffFile;
let diffLines;
- const getDiffFileMock = () => Object.assign({}, diffFileMockData);
+ const getDiffFileMock = () => ({ ...diffFileMockData });
beforeEach(() => {
diffFile = getDiffFileMock();
diff --git a/spec/frontend/diffs/components/edit_button_spec.js b/spec/frontend/diffs/components/edit_button_spec.js
index f9a1d4a84a8..71512c1c4af 100644
--- a/spec/frontend/diffs/components/edit_button_spec.js
+++ b/spec/frontend/diffs/components/edit_button_spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import { GlDeprecatedButton } from '@gitlab/ui';
import EditButton from '~/diffs/components/edit_button.vue';
const editPath = 'test-path';
@@ -22,7 +23,7 @@ describe('EditButton', () => {
canCurrentUserFork: false,
});
- expect(wrapper.attributes('href')).toBe(editPath);
+ expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe(editPath);
});
it('emits a show fork message event if current user can fork', () => {
@@ -30,7 +31,7 @@ describe('EditButton', () => {
editPath,
canCurrentUserFork: true,
});
- wrapper.trigger('click');
+ wrapper.find(GlDeprecatedButton).trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('showForkMessage')).toBeTruthy();
@@ -42,7 +43,7 @@ describe('EditButton', () => {
editPath,
canCurrentUserFork: false,
});
- wrapper.trigger('click');
+ wrapper.find(GlDeprecatedButton).trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('showForkMessage')).toBeFalsy();
@@ -55,10 +56,20 @@ describe('EditButton', () => {
canCurrentUserFork: true,
canModifyBlob: true,
});
- wrapper.trigger('click');
+ wrapper.find(GlDeprecatedButton).trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('showForkMessage')).toBeFalsy();
});
});
+
+ it('disables button if editPath is empty', () => {
+ createComponent({
+ editPath: '',
+ canCurrentUserFork: true,
+ canModifyBlob: true,
+ });
+
+ expect(wrapper.find(GlDeprecatedButton).attributes('disabled')).toBe('true');
+ });
});
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 f423c3b111e..90f012fbafe 100644
--- a/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js
+++ b/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js
@@ -16,7 +16,7 @@ describe('InlineDiffExpansionRow', () => {
isTop: false,
isBottom: false,
};
- const props = Object.assign({}, defaults, options);
+ const props = { ...defaults, ...options };
return createComponentWithStore(cmp, createStore(), props).$mount();
};
diff --git a/spec/frontend/diffs/components/inline_diff_view_spec.js b/spec/frontend/diffs/components/inline_diff_view_spec.js
index a63c13fb271..9b0cf6a84d9 100644
--- a/spec/frontend/diffs/components/inline_diff_view_spec.js
+++ b/spec/frontend/diffs/components/inline_diff_view_spec.js
@@ -8,8 +8,8 @@ import discussionsMockData from '../mock_data/diff_discussions';
describe('InlineDiffView', () => {
let component;
- const getDiffFileMock = () => Object.assign({}, diffFileMockData);
- const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)];
+ const getDiffFileMock = () => ({ ...diffFileMockData });
+ const getDiscussionsMockData = () => [{ ...discussionsMockData }];
const notesLength = getDiscussionsMockData()[0].notes.length;
beforeEach(done => {
diff --git a/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js b/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js
index 15b2a824697..38112445e8d 100644
--- a/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js
+++ b/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js
@@ -16,7 +16,7 @@ describe('ParallelDiffExpansionRow', () => {
isTop: false,
isBottom: false,
};
- const props = Object.assign({}, defaults, options);
+ const props = { ...defaults, ...options };
return createComponentWithStore(cmp, createStore(), props).$mount();
};
diff --git a/spec/frontend/diffs/components/parallel_diff_view_spec.js b/spec/frontend/diffs/components/parallel_diff_view_spec.js
index 0eefbc7ec08..03cf1b72b62 100644
--- a/spec/frontend/diffs/components/parallel_diff_view_spec.js
+++ b/spec/frontend/diffs/components/parallel_diff_view_spec.js
@@ -7,7 +7,7 @@ import diffFileMockData from '../mock_data/diff_file';
describe('ParallelDiffView', () => {
let component;
- const getDiffFileMock = () => Object.assign({}, diffFileMockData);
+ const getDiffFileMock = () => ({ ...diffFileMockData });
beforeEach(() => {
const diffFile = getDiffFileMock();
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index ceccce6312f..3fba661da44 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -40,9 +40,12 @@ import {
receiveFullDiffError,
fetchFullDiff,
toggleFullDiff,
+ switchToFullDiffFromRenamedFile,
setFileCollapsed,
setExpandedDiffLines,
setSuggestPopoverDismissed,
+ changeCurrentCommit,
+ moveToNeighboringCommit,
} from '~/diffs/store/actions';
import eventHub from '~/notes/event_hub';
import * as types from '~/diffs/store/mutation_types';
@@ -312,7 +315,7 @@ describe('DiffsStoreActions', () => {
describe('fetchDiffFilesMeta', () => {
it('should fetch diff meta information', done => {
- const endpointMetadata = '/fetch/diffs_meta?';
+ const endpointMetadata = '/fetch/diffs_meta';
const mock = new MockAdapter(axios);
const data = { diff_files: [] };
const res = { data };
@@ -1250,6 +1253,87 @@ describe('DiffsStoreActions', () => {
});
});
+ describe('switchToFullDiffFromRenamedFile', () => {
+ const SUCCESS_URL = 'fakehost/context.success';
+ const ERROR_URL = 'fakehost/context.error';
+ const testFilePath = 'testpath';
+ const updatedViewerName = 'testviewer';
+ const preparedLine = { prepared: 'in-a-test' };
+ const testFile = {
+ file_path: testFilePath,
+ file_hash: 'testhash',
+ alternate_viewer: { name: updatedViewerName },
+ };
+ const updatedViewer = { name: updatedViewerName, collapsed: false };
+ const testData = [{ rich_text: 'test' }, { rich_text: 'file2' }];
+ let renamedFile;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ jest.spyOn(utils, 'prepareLineForRenamedFile').mockImplementation(() => preparedLine);
+ });
+
+ afterEach(() => {
+ renamedFile = null;
+ mock.restore();
+ });
+
+ describe('success', () => {
+ beforeEach(() => {
+ renamedFile = { ...testFile, context_lines_path: SUCCESS_URL };
+ mock.onGet(SUCCESS_URL).replyOnce(200, testData);
+ });
+
+ it.each`
+ diffViewType
+ ${INLINE_DIFF_VIEW_TYPE}
+ ${PARALLEL_DIFF_VIEW_TYPE}
+ `(
+ 'performs the correct mutations and starts a render queue for view type $diffViewType',
+ ({ diffViewType }) => {
+ return testAction(
+ switchToFullDiffFromRenamedFile,
+ { diffFile: renamedFile },
+ { diffViewType },
+ [
+ {
+ type: types.SET_DIFF_FILE_VIEWER,
+ payload: { filePath: testFilePath, viewer: updatedViewer },
+ },
+ {
+ type: types.SET_CURRENT_VIEW_DIFF_FILE_LINES,
+ payload: { filePath: testFilePath, lines: [preparedLine, preparedLine] },
+ },
+ ],
+ [{ type: 'startRenderDiffsQueue' }],
+ );
+ },
+ );
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ renamedFile = { ...testFile, context_lines_path: ERROR_URL };
+ mock.onGet(ERROR_URL).reply(500);
+ });
+
+ it('dispatches the error handling action', () => {
+ const rejected = testAction(
+ switchToFullDiffFromRenamedFile,
+ { diffFile: renamedFile },
+ null,
+ [],
+ [{ type: 'receiveFullDiffError', payload: testFilePath }],
+ );
+
+ return rejected.catch(error =>
+ expect(error).toEqual(new Error('Request failed with status code 500')),
+ );
+ });
+ });
+ });
+
describe('setFileCollapsed', () => {
it('commits SET_FILE_COLLAPSED', done => {
testAction(
@@ -1347,4 +1431,102 @@ describe('DiffsStoreActions', () => {
);
});
});
+
+ describe('changeCurrentCommit', () => {
+ it('commits the new commit information and re-requests the diff metadata for the commit', () => {
+ return testAction(
+ changeCurrentCommit,
+ { commitId: 'NEW' },
+ {
+ commit: {
+ id: 'OLD',
+ },
+ endpoint: 'URL/OLD',
+ endpointBatch: 'URL/OLD',
+ endpointMetadata: 'URL/OLD',
+ },
+ [
+ { type: types.SET_DIFF_FILES, payload: [] },
+ {
+ type: types.SET_BASE_CONFIG,
+ payload: {
+ commit: {
+ id: 'OLD', // Not a typo: the action fired next will overwrite all of the `commit` in state
+ },
+ endpoint: 'URL/NEW',
+ endpointBatch: 'URL/NEW',
+ endpointMetadata: 'URL/NEW',
+ },
+ },
+ ],
+ [{ type: 'fetchDiffFilesMeta' }],
+ );
+ });
+
+ it.each`
+ commitId | commit | msg
+ ${undefined} | ${{ id: 'OLD' }} | ${'`commitId` is a required argument'}
+ ${'NEW'} | ${null} | ${'`state` must already contain a valid `commit`'}
+ ${undefined} | ${null} | ${'`commitId` is a required argument'}
+ `(
+ 'returns a rejected promise with the error message $msg given `{ "commitId": $commitId, "state.commit": $commit }`',
+ ({ commitId, commit, msg }) => {
+ const err = new Error(msg);
+ const actionReturn = testAction(
+ changeCurrentCommit,
+ { commitId },
+ {
+ endpoint: 'URL/OLD',
+ endpointBatch: 'URL/OLD',
+ endpointMetadata: 'URL/OLD',
+ commit,
+ },
+ [],
+ [],
+ );
+
+ return expect(actionReturn).rejects.toStrictEqual(err);
+ },
+ );
+ });
+
+ describe('moveToNeighboringCommit', () => {
+ it.each`
+ direction | expected | currentCommit
+ ${'next'} | ${'NEXTSHA'} | ${{ next_commit_id: 'NEXTSHA' }}
+ ${'previous'} | ${'PREVIOUSSHA'} | ${{ prev_commit_id: 'PREVIOUSSHA' }}
+ `(
+ 'for the direction "$direction", dispatches the action to move to the SHA "$expected"',
+ ({ direction, expected, currentCommit }) => {
+ return testAction(
+ moveToNeighboringCommit,
+ { direction },
+ { commit: currentCommit },
+ [],
+ [{ type: 'changeCurrentCommit', payload: { commitId: expected } }],
+ );
+ },
+ );
+
+ it.each`
+ direction | diffsAreLoading | currentCommit
+ ${'next'} | ${false} | ${{ prev_commit_id: 'PREVIOUSSHA' }}
+ ${'next'} | ${true} | ${{ prev_commit_id: 'PREVIOUSSHA' }}
+ ${'next'} | ${false} | ${undefined}
+ ${'previous'} | ${false} | ${{ next_commit_id: 'NEXTSHA' }}
+ ${'previous'} | ${true} | ${{ next_commit_id: 'NEXTSHA' }}
+ ${'previous'} | ${false} | ${undefined}
+ `(
+ 'given `{ "isloading": $diffsAreLoading, "commit": $currentCommit }` in state, no actions are dispatched',
+ ({ direction, diffsAreLoading, currentCommit }) => {
+ return testAction(
+ moveToNeighboringCommit,
+ { direction },
+ { commit: currentCommit, isLoading: diffsAreLoading },
+ [],
+ [],
+ );
+ },
+ );
+ });
});
diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js
index ca47f51cb15..dac5be2d656 100644
--- a/spec/frontend/diffs/store/getters_spec.js
+++ b/spec/frontend/diffs/store/getters_spec.js
@@ -14,10 +14,10 @@ describe('Diffs Module Getters', () => {
beforeEach(() => {
localState = state();
- discussionMock = Object.assign({}, discussion);
+ discussionMock = { ...discussion };
discussionMock.diff_file.file_hash = diffFileMock.fileHash;
- discussionMock1 = Object.assign({}, discussion);
+ discussionMock1 = { ...discussion };
discussionMock1.diff_file.file_hash = diffFileMock.fileHash;
});
diff --git a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js
index eb0f2364a50..0343ef75732 100644
--- a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js
+++ b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js
@@ -18,7 +18,6 @@ describe('Compare diff version dropdowns', () => {
};
localState.targetBranchName = 'baseVersion';
localState.mergeRequestDiffs = diffsMockData;
- gon.features = { diffCompareWithHead: true };
});
describe('selectedTargetIndex', () => {
@@ -129,14 +128,6 @@ describe('Compare diff version dropdowns', () => {
});
assertVersions(targetVersions);
});
-
- it('does not list head version if feature flag is not enabled', () => {
- gon.features = { diffCompareWithHead: false };
- setupTest();
- const targetVersions = getters.diffCompareDropdownTargetVersions(localState, getters);
-
- expect(targetVersions.find(version => version.isHead)).toBeUndefined();
- });
});
it('diffCompareDropdownSourceVersions', () => {
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index 858ab5be167..c24d406fef3 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -1041,6 +1041,36 @@ describe('DiffsStoreMutations', () => {
});
});
+ describe('SET_DIFF_FILE_VIEWER', () => {
+ it("should update the correct diffFile's viewer property", () => {
+ const state = {
+ diffFiles: [
+ { file_path: 'SearchString', viewer: 'OLD VIEWER' },
+ { file_path: 'OtherSearchString' },
+ { file_path: 'SomeOtherString' },
+ ],
+ };
+
+ mutations[types.SET_DIFF_FILE_VIEWER](state, {
+ filePath: 'SearchString',
+ viewer: 'NEW VIEWER',
+ });
+
+ expect(state.diffFiles[0].viewer).toEqual('NEW VIEWER');
+ expect(state.diffFiles[1].viewer).not.toBeDefined();
+ expect(state.diffFiles[2].viewer).not.toBeDefined();
+
+ mutations[types.SET_DIFF_FILE_VIEWER](state, {
+ filePath: 'OtherSearchString',
+ viewer: 'NEW VIEWER',
+ });
+
+ expect(state.diffFiles[0].viewer).toEqual('NEW VIEWER');
+ expect(state.diffFiles[1].viewer).toEqual('NEW VIEWER');
+ expect(state.diffFiles[2].viewer).not.toBeDefined();
+ });
+ });
+
describe('SET_SHOW_SUGGEST_POPOVER', () => {
it('sets showSuggestPopover to false', () => {
const state = { showSuggestPopover: true };
diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js
index 1adcdab272a..641373e666f 100644
--- a/spec/frontend/diffs/store/utils_spec.js
+++ b/spec/frontend/diffs/store/utils_spec.js
@@ -361,6 +361,72 @@ describe('DiffsStoreUtils', () => {
});
});
+ describe('prepareLineForRenamedFile', () => {
+ const diffFile = {
+ file_hash: 'file-hash',
+ };
+ const lineIndex = 4;
+ const sourceLine = {
+ foo: 'test',
+ rich_text: ' <p>rich</p>', // Note the leading space
+ };
+ const correctLine = {
+ foo: 'test',
+ line_code: 'file-hash_5_5',
+ old_line: 5,
+ new_line: 5,
+ rich_text: '<p>rich</p>', // Note no leading space
+ discussionsExpanded: true,
+ discussions: [],
+ hasForm: false,
+ text: undefined,
+ alreadyPrepared: true,
+ };
+ let preppedLine;
+
+ beforeEach(() => {
+ preppedLine = utils.prepareLineForRenamedFile({
+ diffViewType: INLINE_DIFF_VIEW_TYPE,
+ line: sourceLine,
+ index: lineIndex,
+ diffFile,
+ });
+ });
+
+ it('copies over the original line object to the new prepared line', () => {
+ expect(preppedLine).toEqual(
+ expect.objectContaining({
+ foo: correctLine.foo,
+ rich_text: correctLine.rich_text,
+ }),
+ );
+ });
+
+ it('correctly sets the old and new lines, plus a line code', () => {
+ expect(preppedLine.old_line).toEqual(correctLine.old_line);
+ expect(preppedLine.new_line).toEqual(correctLine.new_line);
+ expect(preppedLine.line_code).toEqual(correctLine.line_code);
+ });
+
+ it('returns a single object with the correct structure for `inline` lines', () => {
+ expect(preppedLine).toEqual(correctLine);
+ });
+
+ it('returns a nested object with "left" and "right" lines + the line code for `parallel` lines', () => {
+ preppedLine = utils.prepareLineForRenamedFile({
+ diffViewType: PARALLEL_DIFF_VIEW_TYPE,
+ line: sourceLine,
+ index: lineIndex,
+ diffFile,
+ });
+
+ expect(Object.keys(preppedLine)).toEqual(['left', 'right', 'line_code']);
+ expect(preppedLine.left).toEqual(correctLine);
+ expect(preppedLine.right).toEqual(correctLine);
+ expect(preppedLine.line_code).toEqual(correctLine.line_code);
+ });
+ });
+
describe('prepareDiffData', () => {
let mock;
let preparedDiff;
@@ -372,13 +438,13 @@ describe('DiffsStoreUtils', () => {
mock = getDiffFileMock();
preparedDiff = { diff_files: [mock] };
splitInlineDiff = {
- diff_files: [Object.assign({}, mock, { parallel_diff_lines: undefined })],
+ diff_files: [{ ...mock, parallel_diff_lines: undefined }],
};
splitParallelDiff = {
- diff_files: [Object.assign({}, mock, { highlighted_diff_lines: undefined })],
+ diff_files: [{ ...mock, highlighted_diff_lines: undefined }],
};
completedDiff = {
- diff_files: [Object.assign({}, mock, { highlighted_diff_lines: undefined })],
+ diff_files: [{ ...mock, highlighted_diff_lines: undefined }],
};
preparedDiff.diff_files = utils.prepareDiffData(preparedDiff);
@@ -503,11 +569,16 @@ describe('DiffsStoreUtils', () => {
},
};
+ // When multi line comments are fully implemented `line_code` will be
+ // included in all requests. Until then we need to ensure the logic does
+ // not change when it is included only in the "comparison" argument.
+ const lineRange = { start_line_code: 'abc_1_1', end_line_code: 'abc_1_2' };
+
it('returns true when the discussion is up to date', () => {
expect(
utils.isDiscussionApplicableToLine({
discussion: discussions.upToDateDiscussion1,
- diffPosition,
+ diffPosition: { ...diffPosition, line_range: lineRange },
latestDiff: true,
}),
).toBe(true);
@@ -517,7 +588,7 @@ describe('DiffsStoreUtils', () => {
expect(
utils.isDiscussionApplicableToLine({
discussion: discussions.outDatedDiscussion1,
- diffPosition,
+ diffPosition: { ...diffPosition, line_range: lineRange },
latestDiff: true,
}),
).toBe(false);
@@ -534,6 +605,7 @@ describe('DiffsStoreUtils', () => {
diffPosition: {
...diffPosition,
lineCode: 'ABC_1',
+ line_range: lineRange,
},
latestDiff: true,
}),
@@ -551,6 +623,7 @@ describe('DiffsStoreUtils', () => {
diffPosition: {
...diffPosition,
line_code: 'ABC_1',
+ line_range: lineRange,
},
latestDiff: true,
}),
@@ -568,6 +641,7 @@ describe('DiffsStoreUtils', () => {
diffPosition: {
...diffPosition,
lineCode: 'ABC_1',
+ line_range: lineRange,
},
latestDiff: false,
}),
diff --git a/spec/frontend/dirty_submit/dirty_submit_collection_spec.js b/spec/frontend/dirty_submit/dirty_submit_collection_spec.js
new file mode 100644
index 00000000000..170d581be23
--- /dev/null
+++ b/spec/frontend/dirty_submit/dirty_submit_collection_spec.js
@@ -0,0 +1,22 @@
+import DirtySubmitCollection from '~/dirty_submit/dirty_submit_collection';
+import { setInputValue, createForm } from './helper';
+
+jest.mock('lodash/throttle', () => jest.fn(fn => fn));
+
+describe('DirtySubmitCollection', () => {
+ const testElementsCollection = [createForm(), createForm()];
+ const forms = testElementsCollection.map(testElements => testElements.form);
+
+ new DirtySubmitCollection(forms); // eslint-disable-line no-new
+
+ it.each(testElementsCollection)('disables submits until there are changes', testElements => {
+ const { input, submit } = testElements;
+ const originalValue = input.value;
+
+ expect(submit.disabled).toBe(true);
+ setInputValue(input, `${originalValue} changes`);
+ expect(submit.disabled).toBe(false);
+ setInputValue(input, originalValue);
+ expect(submit.disabled).toBe(true);
+ });
+});
diff --git a/spec/javascripts/dirty_submit/dirty_submit_factory_spec.js b/spec/frontend/dirty_submit/dirty_submit_factory_spec.js
index 40843a68582..40843a68582 100644
--- a/spec/javascripts/dirty_submit/dirty_submit_factory_spec.js
+++ b/spec/frontend/dirty_submit/dirty_submit_factory_spec.js
diff --git a/spec/frontend/dirty_submit/dirty_submit_form_spec.js b/spec/frontend/dirty_submit/dirty_submit_form_spec.js
new file mode 100644
index 00000000000..d7f690df1f3
--- /dev/null
+++ b/spec/frontend/dirty_submit/dirty_submit_form_spec.js
@@ -0,0 +1,97 @@
+import { range as rge, throttle } from 'lodash';
+import DirtySubmitForm from '~/dirty_submit/dirty_submit_form';
+import { getInputValue, setInputValue, createForm } from './helper';
+
+jest.mock('lodash/throttle', () => jest.fn(fn => fn));
+const lodash = jest.requireActual('lodash');
+
+function expectToToggleDisableOnDirtyUpdate(submit, input) {
+ const originalValue = getInputValue(input);
+
+ expect(submit.disabled).toBe(true);
+
+ setInputValue(input, `${originalValue} changes`);
+ expect(submit.disabled).toBe(false);
+ setInputValue(input, originalValue);
+ expect(submit.disabled).toBe(true);
+}
+
+describe('DirtySubmitForm', () => {
+ describe('submit button tests', () => {
+ it('disables submit until there are changes', () => {
+ const { form, input, submit } = createForm();
+
+ new DirtySubmitForm(form); // eslint-disable-line no-new
+
+ expectToToggleDisableOnDirtyUpdate(submit, input);
+ });
+
+ it('disables submit until there are changes when initializing with a falsy value', () => {
+ const { form, input, submit } = createForm();
+ input.value = '';
+
+ new DirtySubmitForm(form); // eslint-disable-line no-new
+
+ expectToToggleDisableOnDirtyUpdate(submit, input);
+ });
+
+ it('disables submit until there are changes for radio inputs', () => {
+ const { form, input, submit } = createForm('radio');
+
+ new DirtySubmitForm(form); // eslint-disable-line no-new
+
+ expectToToggleDisableOnDirtyUpdate(submit, input);
+ });
+
+ it('disables submit until there are changes for checkbox inputs', () => {
+ const { form, input, submit } = createForm('checkbox');
+
+ new DirtySubmitForm(form); // eslint-disable-line no-new
+
+ expectToToggleDisableOnDirtyUpdate(submit, input);
+ });
+ });
+
+ describe('throttling tests', () => {
+ beforeEach(() => {
+ throttle.mockImplementation(lodash.throttle);
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ throttle.mockReset();
+ });
+
+ it('throttles updates when rapid changes are made to a single form element', () => {
+ const { form, input } = createForm();
+ const updateDirtyInputSpy = jest.spyOn(new DirtySubmitForm(form), 'updateDirtyInput');
+
+ rge(10).forEach(i => {
+ setInputValue(input, `change ${i}`, false);
+ });
+
+ jest.runOnlyPendingTimers();
+
+ expect(updateDirtyInputSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not throttle updates when rapid changes are made to different form elements', () => {
+ const form = document.createElement('form');
+ const range = rge(10);
+ range.forEach(i => {
+ form.innerHTML += `<input type="text" name="input-${i}" class="js-input-${i}"/>`;
+ });
+
+ const updateDirtyInputSpy = jest.spyOn(new DirtySubmitForm(form), 'updateDirtyInput');
+
+ range.forEach(i => {
+ const input = form.querySelector(`.js-input-${i}`);
+ setInputValue(input, `change`, false);
+ });
+
+ jest.runOnlyPendingTimers();
+
+ expect(updateDirtyInputSpy).toHaveBeenCalledTimes(range.length);
+ });
+ });
+});
diff --git a/spec/frontend/dirty_submit/helper.js b/spec/frontend/dirty_submit/helper.js
new file mode 100644
index 00000000000..c02512b7671
--- /dev/null
+++ b/spec/frontend/dirty_submit/helper.js
@@ -0,0 +1,43 @@
+function isCheckableType(type) {
+ return /^(radio|checkbox)$/.test(type);
+}
+
+export function setInputValue(element, value) {
+ const { type } = element;
+ let eventType;
+
+ if (isCheckableType(type)) {
+ element.checked = !element.checked;
+ eventType = 'change';
+ } else {
+ element.value = value;
+ eventType = 'input';
+ }
+
+ element.dispatchEvent(
+ new Event(eventType, {
+ bubbles: true,
+ }),
+ );
+}
+
+export function getInputValue(input) {
+ return isCheckableType(input.type) ? input.checked : input.value;
+}
+
+export function createForm(type = 'text') {
+ const form = document.createElement('form');
+ form.innerHTML = `
+ <input type="${type}" name="${type}" class="js-input"/>
+ <button type="submit" class="js-dirty-submit"></button>
+ `;
+
+ const input = form.querySelector('.js-input');
+ const submit = form.querySelector('.js-dirty-submit');
+
+ return {
+ form,
+ input,
+ submit,
+ };
+}
diff --git a/spec/frontend/editor/editor_lite_spec.js b/spec/frontend/editor/editor_lite_spec.js
new file mode 100644
index 00000000000..cb07bcf8f28
--- /dev/null
+++ b/spec/frontend/editor/editor_lite_spec.js
@@ -0,0 +1,177 @@
+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';
+
+describe('Base editor', () => {
+ let editorEl;
+ let editor;
+ const blobContent = 'Foo Bar';
+ const blobPath = 'test.md';
+ const uri = new Uri('gitlab', false, blobPath);
+ const fakeModel = { foo: 'bar' };
+
+ beforeEach(() => {
+ setFixtures('<div id="editor" data-editor-loading></div>');
+ editorEl = document.getElementById('editor');
+ editor = new Editor();
+ });
+
+ afterEach(() => {
+ editor.dispose();
+ editorEl.remove();
+ });
+
+ it('initializes Editor with basic properties', () => {
+ expect(editor).toBeDefined();
+ expect(editor.editorEl).toBe(null);
+ expect(editor.blobContent).toEqual('');
+ expect(editor.blobPath).toEqual('');
+ });
+
+ it('removes `editor-loading` data attribute from the target DOM element', () => {
+ editor.createInstance({ el: editorEl });
+
+ expect(editorEl.dataset.editorLoading).toBeUndefined();
+ });
+
+ describe('instance of the Editor', () => {
+ let modelSpy;
+ let instanceSpy;
+ let setModel;
+ let dispose;
+
+ beforeEach(() => {
+ setModel = jest.fn();
+ dispose = jest.fn();
+ modelSpy = jest.spyOn(monacoEditor, 'createModel').mockImplementation(() => fakeModel);
+ instanceSpy = jest.spyOn(monacoEditor, 'create').mockImplementation(() => ({
+ setModel,
+ dispose,
+ }));
+ });
+
+ it('does nothing if no dom element is supplied', () => {
+ editor.createInstance();
+
+ expect(editor.editorEl).toBe(null);
+ expect(editor.blobContent).toEqual('');
+ expect(editor.blobPath).toEqual('');
+
+ expect(modelSpy).not.toHaveBeenCalled();
+ expect(instanceSpy).not.toHaveBeenCalled();
+ expect(setModel).not.toHaveBeenCalled();
+ });
+
+ it('creates model to be supplied to Monaco editor', () => {
+ editor.createInstance({ el: editorEl, blobPath, blobContent });
+
+ expect(modelSpy).toHaveBeenCalledWith(blobContent, undefined, uri);
+ expect(setModel).toHaveBeenCalledWith(fakeModel);
+ });
+
+ it('initializes the instance on a supplied DOM node', () => {
+ editor.createInstance({ el: editorEl });
+
+ expect(editor.editorEl).not.toBe(null);
+ expect(instanceSpy).toHaveBeenCalledWith(editorEl, expect.anything());
+ });
+ });
+
+ describe('implementation', () => {
+ beforeEach(() => {
+ editor.createInstance({ el: editorEl, blobPath, blobContent });
+ });
+
+ afterEach(() => {
+ editor.model.dispose();
+ });
+
+ it('correctly proxies value from the model', () => {
+ expect(editor.getValue()).toEqual(blobContent);
+ });
+
+ it('is capable of changing the language of the model', () => {
+ // ignore warnings and errors Monaco posts during setup
+ // (due to being called from Jest/Node.js environment)
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ const blobRenamedPath = 'test.js';
+
+ expect(editor.model.getLanguageIdentifier().language).toEqual('markdown');
+ editor.updateModelLanguage(blobRenamedPath);
+
+ expect(editor.model.getLanguageIdentifier().language).toEqual('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);
+
+ expect(spy).not.toHaveBeenCalled();
+ expect(editor.model.getLanguageIdentifier().language).toEqual('plaintext');
+ });
+ });
+
+ describe('languages', () => {
+ it('registers custom languages defined with Monaco', () => {
+ expect(monacoLanguages.getLanguages()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: 'vue',
+ }),
+ ]),
+ );
+ });
+ });
+
+ describe('syntax highlighting theme', () => {
+ let themeDefineSpy;
+ let themeSetSpy;
+ let defaultScheme;
+
+ beforeEach(() => {
+ themeDefineSpy = jest.spyOn(monacoEditor, 'defineTheme').mockImplementation(() => {});
+ themeSetSpy = jest.spyOn(monacoEditor, 'setTheme').mockImplementation(() => {});
+ defaultScheme = window.gon.user_color_scheme;
+ });
+
+ afterEach(() => {
+ window.gon.user_color_scheme = defaultScheme;
+ });
+
+ it('sets default syntax highlighting theme', () => {
+ const expectedTheme = themes.find(t => t.name === DEFAULT_THEME);
+
+ editor = new Editor();
+
+ expect(themeDefineSpy).toHaveBeenCalledWith(DEFAULT_THEME, expectedTheme.data);
+ expect(themeSetSpy).toHaveBeenCalledWith(DEFAULT_THEME);
+ });
+
+ it('sets correct theme if it is set in users preferences', () => {
+ const expectedTheme = themes.find(t => t.name !== DEFAULT_THEME);
+
+ expect(expectedTheme.name).not.toBe(DEFAULT_THEME);
+
+ window.gon.user_color_scheme = expectedTheme.name;
+ editor = new Editor();
+
+ expect(themeDefineSpy).toHaveBeenCalledWith(expectedTheme.name, expectedTheme.data);
+ expect(themeSetSpy).toHaveBeenCalledWith(expectedTheme.name);
+ });
+
+ it('falls back to default theme if a selected one is not supported yet', () => {
+ const name = 'non-existent-theme';
+ const nonExistentTheme = { name };
+
+ window.gon.user_color_scheme = nonExistentTheme.name;
+ editor = new Editor();
+
+ expect(themeDefineSpy).not.toHaveBeenCalled();
+ expect(themeSetSpy).toHaveBeenCalledWith(DEFAULT_THEME);
+ });
+ });
+});
diff --git a/spec/frontend/emoji_spec.js b/spec/frontend/emoji_spec.js
new file mode 100644
index 00000000000..25bc95e0dd6
--- /dev/null
+++ b/spec/frontend/emoji_spec.js
@@ -0,0 +1,485 @@
+import { glEmojiTag } from '~/emoji';
+import isEmojiUnicodeSupported, {
+ isFlagEmoji,
+ isRainbowFlagEmoji,
+ isKeycapEmoji,
+ isSkinToneComboEmoji,
+ isHorceRacingSkinToneComboEmoji,
+ isPersonZwjEmoji,
+} from '~/emoji/support/is_emoji_unicode_supported';
+
+const emptySupportMap = {
+ personZwj: false,
+ horseRacing: false,
+ flag: false,
+ skinToneModifier: false,
+ '9.0': false,
+ '8.0': false,
+ '7.0': false,
+ 6.1: false,
+ '6.0': false,
+ 5.2: false,
+ 5.1: false,
+ 4.1: false,
+ '4.0': false,
+ 3.2: false,
+ '3.0': false,
+ 1.1: false,
+};
+
+const emojiFixtureMap = {
+ bomb: {
+ name: 'bomb',
+ moji: '💣',
+ unicodeVersion: '6.0',
+ },
+ construction_worker_tone5: {
+ name: 'construction_worker_tone5',
+ moji: '👷🏿',
+ unicodeVersion: '8.0',
+ },
+ five: {
+ name: 'five',
+ moji: '5️⃣',
+ unicodeVersion: '3.0',
+ },
+ grey_question: {
+ name: 'grey_question',
+ moji: '❔',
+ unicodeVersion: '6.0',
+ },
+};
+
+function markupToDomElement(markup) {
+ const div = document.createElement('div');
+ div.innerHTML = markup;
+ return div.firstElementChild;
+}
+
+function testGlEmojiImageFallback(element, name, src) {
+ expect(element.tagName.toLowerCase()).toBe('img');
+ expect(element.getAttribute('src')).toBe(src);
+ expect(element.getAttribute('title')).toBe(`:${name}:`);
+ expect(element.getAttribute('alt')).toBe(`:${name}:`);
+}
+
+const defaults = {
+ forceFallback: false,
+ sprite: false,
+};
+
+function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options = {}) {
+ const opts = { ...defaults, ...options };
+ expect(element.tagName.toLowerCase()).toBe('gl-emoji');
+ expect(element.dataset.name).toBe(name);
+ expect(element.dataset.fallbackSrc.length).toBeGreaterThan(0);
+ expect(element.dataset.unicodeVersion).toBe(unicodeVersion);
+
+ const fallbackSpriteClass = `emoji-${name}`;
+ if (opts.sprite) {
+ expect(element.dataset.fallbackSpriteClass).toBe(fallbackSpriteClass);
+ }
+
+ if (opts.forceFallback && opts.sprite) {
+ expect(element.getAttribute('class')).toBe(`emoji-icon ${fallbackSpriteClass}`);
+ }
+
+ if (opts.forceFallback && !opts.sprite) {
+ // Check for image fallback
+ testGlEmojiImageFallback(element.firstElementChild, name, element.dataset.fallbackSrc);
+ } else {
+ // Otherwise make sure things are still unicode text
+ expect(element.textContent.trim()).toBe(unicodeMoji);
+ }
+}
+
+describe('gl_emoji', () => {
+ describe('glEmojiTag', () => {
+ it('bomb emoji', () => {
+ const emojiKey = 'bomb';
+ const markup = glEmojiTag(emojiFixtureMap[emojiKey].name);
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ );
+ });
+
+ it('bomb emoji with image fallback', () => {
+ const emojiKey = 'bomb';
+ const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
+ forceFallback: true,
+ });
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ {
+ forceFallback: true,
+ },
+ );
+ });
+
+ it('bomb emoji with sprite fallback readiness', () => {
+ const emojiKey = 'bomb';
+ const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
+ sprite: true,
+ });
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ {
+ sprite: true,
+ },
+ );
+ });
+
+ it('bomb emoji with sprite fallback', () => {
+ const emojiKey = 'bomb';
+ const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
+ forceFallback: true,
+ sprite: true,
+ });
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ {
+ forceFallback: true,
+ sprite: true,
+ },
+ );
+ });
+
+ it('question mark when invalid emoji name given', () => {
+ const name = 'invalid_emoji';
+ const emojiKey = 'grey_question';
+ const markup = glEmojiTag(name);
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ );
+ });
+
+ it('question mark with image fallback when invalid emoji name given', () => {
+ const name = 'invalid_emoji';
+ const emojiKey = 'grey_question';
+ const markup = glEmojiTag(name, {
+ forceFallback: true,
+ });
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ {
+ forceFallback: true,
+ },
+ );
+ });
+ });
+
+ describe('isFlagEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isFlagEmoji('')).toBeFalsy();
+ });
+
+ it('should detect flag_ac', () => {
+ expect(isFlagEmoji('🇦🇨')).toBeTruthy();
+ });
+
+ it('should detect flag_us', () => {
+ expect(isFlagEmoji('🇺🇸')).toBeTruthy();
+ });
+
+ it('should detect flag_zw', () => {
+ expect(isFlagEmoji('🇿🇼')).toBeTruthy();
+ });
+
+ it('should not detect flags', () => {
+ expect(isFlagEmoji('🎏')).toBeFalsy();
+ });
+
+ it('should not detect triangular_flag_on_post', () => {
+ expect(isFlagEmoji('🚩')).toBeFalsy();
+ });
+
+ it('should not detect single letter', () => {
+ expect(isFlagEmoji('🇦')).toBeFalsy();
+ });
+
+ it('should not detect >2 letters', () => {
+ expect(isFlagEmoji('🇦🇧🇨')).toBeFalsy();
+ });
+ });
+
+ describe('isRainbowFlagEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isRainbowFlagEmoji('')).toBeFalsy();
+ });
+
+ it('should detect rainbow_flag', () => {
+ expect(isRainbowFlagEmoji('🏳🌈')).toBeTruthy();
+ });
+
+ it("should not detect flag_white on its' own", () => {
+ expect(isRainbowFlagEmoji('🏳')).toBeFalsy();
+ });
+
+ it("should not detect rainbow on its' own", () => {
+ expect(isRainbowFlagEmoji('🌈')).toBeFalsy();
+ });
+
+ it('should not detect flag_white with something else', () => {
+ expect(isRainbowFlagEmoji('🏳🔵')).toBeFalsy();
+ });
+ });
+
+ describe('isKeycapEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isKeycapEmoji('')).toBeFalsy();
+ });
+
+ it('should detect one(keycap)', () => {
+ expect(isKeycapEmoji('1️⃣')).toBeTruthy();
+ });
+
+ it('should detect nine(keycap)', () => {
+ expect(isKeycapEmoji('9️⃣')).toBeTruthy();
+ });
+
+ it('should not detect ten(keycap)', () => {
+ expect(isKeycapEmoji('🔟')).toBeFalsy();
+ });
+
+ it('should not detect hash(keycap)', () => {
+ expect(isKeycapEmoji('#⃣')).toBeFalsy();
+ });
+ });
+
+ describe('isSkinToneComboEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isSkinToneComboEmoji('')).toBeFalsy();
+ });
+
+ it('should detect hand_splayed_tone5', () => {
+ expect(isSkinToneComboEmoji('🖐🏿')).toBeTruthy();
+ });
+
+ it('should not detect hand_splayed', () => {
+ expect(isSkinToneComboEmoji('🖐')).toBeFalsy();
+ });
+
+ it('should detect lifter_tone1', () => {
+ expect(isSkinToneComboEmoji('🏋🏻')).toBeTruthy();
+ });
+
+ it('should not detect lifter', () => {
+ expect(isSkinToneComboEmoji('🏋')).toBeFalsy();
+ });
+
+ it('should detect rowboat_tone4', () => {
+ expect(isSkinToneComboEmoji('🚣🏾')).toBeTruthy();
+ });
+
+ it('should not detect rowboat', () => {
+ expect(isSkinToneComboEmoji('🚣')).toBeFalsy();
+ });
+
+ it('should not detect individual tone emoji', () => {
+ expect(isSkinToneComboEmoji('🏻')).toBeFalsy();
+ });
+ });
+
+ describe('isHorceRacingSkinToneComboEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isHorceRacingSkinToneComboEmoji('')).toBeFalsy();
+ });
+
+ it('should detect horse_racing_tone2', () => {
+ expect(isHorceRacingSkinToneComboEmoji('🏇🏼')).toBeTruthy();
+ });
+
+ it('should not detect horse_racing', () => {
+ expect(isHorceRacingSkinToneComboEmoji('🏇')).toBeFalsy();
+ });
+ });
+
+ describe('isPersonZwjEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isPersonZwjEmoji('')).toBeFalsy();
+ });
+
+ it('should detect couple_mm', () => {
+ expect(isPersonZwjEmoji('👨‍❤️‍👨')).toBeTruthy();
+ });
+
+ it('should not detect couple_with_heart', () => {
+ expect(isPersonZwjEmoji('💑')).toBeFalsy();
+ });
+
+ it('should not detect couplekiss', () => {
+ expect(isPersonZwjEmoji('💏')).toBeFalsy();
+ });
+
+ it('should detect family_mmb', () => {
+ expect(isPersonZwjEmoji('👨‍👨‍👦')).toBeTruthy();
+ });
+
+ it('should detect family_mwgb', () => {
+ expect(isPersonZwjEmoji('👨‍👩‍👧‍👦')).toBeTruthy();
+ });
+
+ it('should not detect family', () => {
+ expect(isPersonZwjEmoji('👪')).toBeFalsy();
+ });
+
+ it('should detect kiss_ww', () => {
+ expect(isPersonZwjEmoji('👩‍❤️‍💋‍👩')).toBeTruthy();
+ });
+
+ it('should not detect girl', () => {
+ expect(isPersonZwjEmoji('👧')).toBeFalsy();
+ });
+
+ it('should not detect girl_tone5', () => {
+ expect(isPersonZwjEmoji('👧🏿')).toBeFalsy();
+ });
+
+ it('should not detect man', () => {
+ expect(isPersonZwjEmoji('👨')).toBeFalsy();
+ });
+
+ it('should not detect woman', () => {
+ expect(isPersonZwjEmoji('👩')).toBeFalsy();
+ });
+ });
+
+ describe('isEmojiUnicodeSupported', () => {
+ it('should gracefully handle empty string with unicode support', () => {
+ const isSupported = isEmojiUnicodeSupported({ '1.0': true }, '', '1.0');
+
+ expect(isSupported).toBeTruthy();
+ });
+
+ it('should gracefully handle empty string without unicode support', () => {
+ const isSupported = isEmojiUnicodeSupported({}, '', '1.0');
+
+ expect(isSupported).toBeFalsy();
+ });
+
+ it('bomb(6.0) with 6.0 support', () => {
+ const emojiKey = 'bomb';
+ const unicodeSupportMap = { ...emptySupportMap, '6.0': true };
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+
+ expect(isSupported).toBeTruthy();
+ });
+
+ it('bomb(6.0) without 6.0 support', () => {
+ const emojiKey = 'bomb';
+ const unicodeSupportMap = emptySupportMap;
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+
+ expect(isSupported).toBeFalsy();
+ });
+
+ it('bomb(6.0) without 6.0 but with 9.0 support', () => {
+ const emojiKey = 'bomb';
+ const unicodeSupportMap = { ...emptySupportMap, '9.0': true };
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+
+ expect(isSupported).toBeFalsy();
+ });
+
+ it('construction_worker_tone5(8.0) without skin tone modifier support', () => {
+ const emojiKey = 'construction_worker_tone5';
+ const unicodeSupportMap = {
+ ...emptySupportMap,
+ skinToneModifier: false,
+ '9.0': true,
+ '8.0': true,
+ '7.0': true,
+ 6.1: true,
+ '6.0': true,
+ 5.2: true,
+ 5.1: true,
+ 4.1: true,
+ '4.0': true,
+ 3.2: true,
+ '3.0': true,
+ 1.1: true,
+ };
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+
+ expect(isSupported).toBeFalsy();
+ });
+
+ it('use native keycap on >=57 chrome', () => {
+ const emojiKey = 'five';
+ const unicodeSupportMap = {
+ ...emptySupportMap,
+ '3.0': true,
+ meta: {
+ isChrome: true,
+ chromeVersion: 57,
+ },
+ };
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+
+ expect(isSupported).toBeTruthy();
+ });
+
+ it('fallback keycap on <57 chrome', () => {
+ const emojiKey = 'five';
+ const unicodeSupportMap = {
+ ...emptySupportMap,
+ '3.0': true,
+ meta: {
+ isChrome: true,
+ chromeVersion: 50,
+ },
+ };
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+
+ expect(isSupported).toBeFalsy();
+ });
+ });
+});
diff --git a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js
new file mode 100644
index 00000000000..2c3c3e3267a
--- /dev/null
+++ b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js
@@ -0,0 +1,62 @@
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import { getSelector, dismiss, inserted } from '~/feature_highlight/feature_highlight_helper';
+import { togglePopover } from '~/shared/popover';
+
+describe('feature highlight helper', () => {
+ describe('getSelector', () => {
+ it('returns js-feature-highlight selector', () => {
+ const highlightId = 'highlightId';
+
+ expect(getSelector(highlightId)).toEqual(
+ `.js-feature-highlight[data-highlight=${highlightId}]`,
+ );
+ });
+ });
+
+ describe('dismiss', () => {
+ const context = {
+ hide: () => {},
+ attr: () => '/-/callouts/dismiss',
+ };
+
+ beforeEach(() => {
+ jest.spyOn(axios, 'post').mockResolvedValue();
+ jest.spyOn(togglePopover, 'call').mockImplementation(() => {});
+ jest.spyOn(context, 'hide').mockImplementation(() => {});
+ dismiss.call(context);
+ });
+
+ it('calls persistent dismissal endpoint', () => {
+ expect(axios.post).toHaveBeenCalledWith(
+ '/-/callouts/dismiss',
+ expect.objectContaining({ feature_name: undefined }),
+ );
+ });
+
+ it('calls hide popover', () => {
+ expect(togglePopover.call).toHaveBeenCalledWith(context, false);
+ });
+
+ it('calls hide', () => {
+ expect(context.hide).toHaveBeenCalled();
+ });
+ });
+
+ describe('inserted', () => {
+ it('registers click event callback', done => {
+ const context = {
+ getAttribute: () => 'popoverId',
+ dataset: {
+ highlight: 'some-feature',
+ },
+ };
+
+ jest.spyOn($.fn, 'on').mockImplementation(event => {
+ expect(event).toEqual('click');
+ done();
+ });
+ inserted.call(context);
+ });
+ });
+});
diff --git a/spec/frontend/feature_highlight/feature_highlight_options_spec.js b/spec/frontend/feature_highlight/feature_highlight_options_spec.js
index 8b75c46fd4c..f82f984cb7f 100644
--- a/spec/frontend/feature_highlight/feature_highlight_options_spec.js
+++ b/spec/frontend/feature_highlight/feature_highlight_options_spec.js
@@ -3,34 +3,20 @@ import domContentLoaded from '~/feature_highlight/feature_highlight_options';
describe('feature highlight options', () => {
describe('domContentLoaded', () => {
- it('should not call highlightFeatures when breakpoint is xs', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xs');
-
- expect(domContentLoaded()).toBe(false);
- });
-
- it('should not call highlightFeatures when breakpoint is sm', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('sm');
-
- expect(domContentLoaded()).toBe(false);
- });
-
- it('should not call highlightFeatures when breakpoint is md', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md');
-
- expect(domContentLoaded()).toBe(false);
- });
-
- it('should not call highlightFeatures when breakpoint is not xl', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('lg');
-
- expect(domContentLoaded()).toBe(false);
- });
-
- it('should call highlightFeatures when breakpoint is xl', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl');
-
- expect(domContentLoaded()).toBe(true);
- });
+ it.each`
+ breakPoint | shouldCall
+ ${'xs'} | ${false}
+ ${'sm'} | ${false}
+ ${'md'} | ${false}
+ ${'lg'} | ${false}
+ ${'xl'} | ${true}
+ `(
+ 'when breakpoint is $breakPoint should call highlightFeatures is $shouldCall',
+ ({ breakPoint, shouldCall }) => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue(breakPoint);
+
+ expect(domContentLoaded()).toBe(shouldCall);
+ },
+ );
});
});
diff --git a/spec/frontend/feature_highlight/feature_highlight_spec.js b/spec/frontend/feature_highlight/feature_highlight_spec.js
new file mode 100644
index 00000000000..79c4050c8c4
--- /dev/null
+++ b/spec/frontend/feature_highlight/feature_highlight_spec.js
@@ -0,0 +1,120 @@
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import * as featureHighlight from '~/feature_highlight/feature_highlight';
+import * as popover from '~/shared/popover';
+import axios from '~/lib/utils/axios_utils';
+
+jest.mock('~/shared/popover');
+
+describe('feature highlight', () => {
+ beforeEach(() => {
+ setFixtures(`
+ <div>
+ <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" data-dismiss-endpoint="/test" disabled>
+ Trigger
+ </div>
+ </div>
+ <div class="feature-highlight-popover-content">
+ Content
+ <div class="dismiss-feature-highlight">
+ Dismiss
+ </div>
+ </div>
+ `);
+ });
+
+ describe('setupFeatureHighlightPopover', () => {
+ let mock;
+ const selector = '.js-feature-highlight[data-highlight=test]';
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet('/test').reply(200);
+ jest.spyOn(window, 'addEventListener').mockImplementation(() => {});
+ featureHighlight.setupFeatureHighlightPopover('test', 0);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('setup popover content', () => {
+ const $popoverContent = $('.feature-highlight-popover-content');
+ const outerHTML = $popoverContent.prop('outerHTML');
+
+ expect($(selector).data('content')).toEqual(outerHTML);
+ });
+
+ it('setup mouseenter', () => {
+ $(selector).trigger('mouseenter');
+
+ expect(popover.mouseenter).toHaveBeenCalledWith(expect.any(Object));
+ });
+
+ it('setup debounced mouseleave', () => {
+ $(selector).trigger('mouseleave');
+
+ expect(popover.debouncedMouseleave).toHaveBeenCalled();
+ });
+
+ it('setup show.bs.popover', () => {
+ $(selector).trigger('show.bs.popover');
+
+ expect(window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function), {
+ once: true,
+ });
+ });
+
+ it('removes disabled attribute', () => {
+ expect($('.js-feature-highlight').is(':disabled')).toEqual(false);
+ });
+ });
+
+ describe('findHighestPriorityFeature', () => {
+ beforeEach(() => {
+ setFixtures(`
+ <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
+ `);
+ });
+
+ it('should pick the highest priority feature highlight', () => {
+ setFixtures(`
+ <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
+ `);
+
+ expect($('.js-feature-highlight').length).toBeGreaterThan(1);
+ expect(featureHighlight.findHighestPriorityFeature()).toEqual('test-high-priority');
+ });
+
+ it('should work when no priority is set', () => {
+ setFixtures(`
+ <div class="js-feature-highlight" data-highlight="test" disabled></div>
+ `);
+
+ expect(featureHighlight.findHighestPriorityFeature()).toEqual('test');
+ });
+
+ it('should pick the highest priority feature highlight when some have no priority set', () => {
+ setFixtures(`
+ <div class="js-feature-highlight" data-highlight="test-no-priority1" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-no-priority2" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
+ `);
+
+ expect($('.js-feature-highlight').length).toBeGreaterThan(1);
+ expect(featureHighlight.findHighestPriorityFeature()).toEqual('test-high-priority');
+ });
+ });
+
+ describe('highlightFeatures', () => {
+ it('calls setupFeatureHighlightPopover', () => {
+ expect(featureHighlight.highlightFeatures()).toEqual('test');
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/dropdown_utils_spec.js b/spec/frontend/filtered_search/dropdown_utils_spec.js
new file mode 100644
index 00000000000..3320b6b0942
--- /dev/null
+++ b/spec/frontend/filtered_search/dropdown_utils_spec.js
@@ -0,0 +1,374 @@
+import DropdownUtils from '~/filtered_search/dropdown_utils';
+import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
+import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
+
+describe('Dropdown Utils', () => {
+ const issueListFixture = 'issues/issue_list.html';
+ preloadFixtures(issueListFixture);
+
+ describe('getEscapedText', () => {
+ it('should return same word when it has no space', () => {
+ const escaped = DropdownUtils.getEscapedText('textWithoutSpace');
+
+ expect(escaped).toBe('textWithoutSpace');
+ });
+
+ it('should escape with double quotes', () => {
+ let escaped = DropdownUtils.getEscapedText('text with space');
+
+ expect(escaped).toBe('"text with space"');
+
+ escaped = DropdownUtils.getEscapedText("won't fix");
+
+ expect(escaped).toBe('"won\'t fix"');
+ });
+
+ it('should escape with single quotes', () => {
+ const escaped = DropdownUtils.getEscapedText('won"t fix');
+
+ expect(escaped).toBe("'won\"t fix'");
+ });
+
+ it('should escape with single quotes by default', () => {
+ const escaped = DropdownUtils.getEscapedText('won"t\' fix');
+
+ expect(escaped).toBe("'won\"t' fix'");
+ });
+ });
+
+ describe('filterWithSymbol', () => {
+ let input;
+ const item = {
+ title: '@root',
+ };
+
+ beforeEach(() => {
+ setFixtures(`
+ <input type="text" id="test" />
+ `);
+
+ input = document.getElementById('test');
+ });
+
+ it('should filter without symbol', () => {
+ input.value = 'roo';
+
+ const updatedItem = DropdownUtils.filterWithSymbol('@', input, item);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with symbol', () => {
+ input.value = '@roo';
+
+ const updatedItem = DropdownUtils.filterWithSymbol('@', input, item);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ describe('filters multiple word title', () => {
+ const multipleWordItem = {
+ title: 'Community Contributions',
+ };
+
+ it('should filter with double quote', () => {
+ input.value = '"';
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with double quote and symbol', () => {
+ input.value = '~"';
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with double quote and multiple words', () => {
+ input.value = '"community con';
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with double quote, symbol and multiple words', () => {
+ input.value = '~"community con';
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with single quote', () => {
+ input.value = "'";
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with single quote and symbol', () => {
+ input.value = "~'";
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with single quote and multiple words', () => {
+ input.value = "'community con";
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with single quote, symbol and multiple words', () => {
+ input.value = "~'community con";
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+ });
+ });
+
+ describe('filterHint', () => {
+ let input;
+ let allowedKeys;
+
+ beforeEach(() => {
+ setFixtures(`
+ <ul class="tokens-container">
+ <li class="input-token">
+ <input class="filtered-search" type="text" id="test" />
+ </li>
+ </ul>
+ `);
+
+ input = document.getElementById('test');
+ allowedKeys = IssuableFilteredSearchTokenKeys.getKeys();
+ });
+
+ function config() {
+ return {
+ input,
+ allowedKeys,
+ };
+ }
+
+ it('should filter', () => {
+ input.value = 'l';
+ let updatedItem = DropdownUtils.filterHint(config(), {
+ hint: 'label',
+ });
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+
+ input.value = 'o';
+ updatedItem = DropdownUtils.filterHint(config(), {
+ hint: 'label',
+ });
+
+ expect(updatedItem.droplab_hidden).toBe(true);
+ });
+
+ it('should return droplab_hidden false when item has no hint', () => {
+ const updatedItem = DropdownUtils.filterHint(config(), {}, '');
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should allow multiple if item.type is array', () => {
+ input.value = 'label:~first la';
+ const updatedItem = DropdownUtils.filterHint(config(), {
+ hint: 'label',
+ type: 'array',
+ });
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should prevent multiple if item.type is not array', () => {
+ input.value = 'milestone:~first mile';
+ let updatedItem = DropdownUtils.filterHint(config(), {
+ hint: 'milestone',
+ });
+
+ expect(updatedItem.droplab_hidden).toBe(true);
+
+ updatedItem = DropdownUtils.filterHint(config(), {
+ hint: 'milestone',
+ type: 'string',
+ });
+
+ expect(updatedItem.droplab_hidden).toBe(true);
+ });
+ });
+
+ describe('setDataValueIfSelected', () => {
+ beforeEach(() => {
+ jest.spyOn(FilteredSearchDropdownManager, 'addWordToInput').mockImplementation(() => {});
+ });
+
+ it('calls addWordToInput when dataValue exists', () => {
+ const selected = {
+ getAttribute: () => 'value',
+ hasAttribute: () => false,
+ };
+
+ DropdownUtils.setDataValueIfSelected(null, '=', selected);
+
+ expect(FilteredSearchDropdownManager.addWordToInput.mock.calls.length).toEqual(1);
+ });
+
+ it('returns true when dataValue exists', () => {
+ const selected = {
+ getAttribute: () => 'value',
+ hasAttribute: () => false,
+ };
+
+ const result = DropdownUtils.setDataValueIfSelected(null, '=', selected);
+ const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected);
+
+ expect(result).toBe(true);
+ expect(result2).toBe(true);
+ });
+
+ it('returns false when dataValue does not exist', () => {
+ const selected = {
+ getAttribute: () => null,
+ };
+
+ const result = DropdownUtils.setDataValueIfSelected(null, '=', selected);
+ const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected);
+
+ expect(result).toBe(false);
+ expect(result2).toBe(false);
+ });
+ });
+
+ describe('getInputSelectionPosition', () => {
+ describe('word with trailing spaces', () => {
+ const value = 'label:none ';
+
+ it('should return selectionStart when cursor is at the trailing space', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 11,
+ value,
+ });
+
+ expect(left).toBe(11);
+ expect(right).toBe(11);
+ });
+
+ it('should return input when cursor is at the start of input', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 0,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(10);
+ });
+
+ it('should return input when cursor is at the middle of input', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 7,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(10);
+ });
+
+ it('should return input when cursor is at the end of input', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 10,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(10);
+ });
+ });
+
+ describe('multiple words', () => {
+ const value = 'label:~"Community Contribution"';
+
+ it('should return input when cursor is after the first word', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 17,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(31);
+ });
+
+ it('should return input when cursor is before the second word', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 18,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(31);
+ });
+ });
+
+ describe('incomplete multiple words', () => {
+ const value = 'label:~"Community Contribution';
+
+ it('should return entire input when cursor is at the start of input', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 0,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(30);
+ });
+
+ it('should return entire input when cursor is at the end of input', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 30,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(30);
+ });
+ });
+ });
+
+ describe('getSearchQuery', () => {
+ let authorToken;
+
+ beforeEach(() => {
+ loadFixtures(issueListFixture);
+
+ authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
+ const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term');
+
+ const tokensContainer = document.querySelector('.tokens-container');
+ tokensContainer.appendChild(searchTermToken);
+ tokensContainer.appendChild(authorToken);
+ });
+
+ it('uses original value if present', () => {
+ const originalValue = 'original dance';
+ const valueContainer = authorToken.querySelector('.value-container');
+ valueContainer.dataset.originalValue = originalValue;
+
+ const searchQuery = DropdownUtils.getSearchQuery();
+
+ expect(searchQuery).toBe(' search term author:=original dance');
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js
new file mode 100644
index 00000000000..ef87662a1ef
--- /dev/null
+++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js
@@ -0,0 +1,587 @@
+import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
+import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error';
+import RecentSearchesRoot from '~/filtered_search/recent_searches_root';
+import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import '~/lib/utils/common_utils';
+import DropdownUtils from '~/filtered_search/dropdown_utils';
+import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
+import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
+import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
+import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
+import { BACKSPACE_KEY_CODE, DELETE_KEY_CODE } from '~/lib/utils/keycodes';
+import { visitUrl } from '~/lib/utils/url_utility';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ visitUrl: jest.fn(),
+}));
+
+describe('Filtered Search Manager', () => {
+ let input;
+ let manager;
+ let tokensContainer;
+ const page = 'issues';
+ const placeholder = 'Search or filter results...';
+
+ function dispatchBackspaceEvent(element, eventType) {
+ const event = new Event(eventType);
+ event.keyCode = BACKSPACE_KEY_CODE;
+ element.dispatchEvent(event);
+ }
+
+ function dispatchDeleteEvent(element, eventType) {
+ const event = new Event(eventType);
+ event.keyCode = DELETE_KEY_CODE;
+ element.dispatchEvent(event);
+ }
+
+ function dispatchAltBackspaceEvent(element, eventType) {
+ const event = new Event(eventType);
+ event.altKey = true;
+ event.keyCode = BACKSPACE_KEY_CODE;
+ element.dispatchEvent(event);
+ }
+
+ function dispatchCtrlBackspaceEvent(element, eventType) {
+ const event = new Event(eventType);
+ event.ctrlKey = true;
+ event.keyCode = BACKSPACE_KEY_CODE;
+ element.dispatchEvent(event);
+ }
+
+ function dispatchMetaBackspaceEvent(element, eventType) {
+ const event = new Event(eventType);
+ event.metaKey = true;
+ event.keyCode = BACKSPACE_KEY_CODE;
+ element.dispatchEvent(event);
+ }
+
+ function getVisualTokens() {
+ return tokensContainer.querySelectorAll('.js-visual-token');
+ }
+
+ beforeEach(() => {
+ setFixtures(`
+ <div class="filtered-search-box">
+ <form>
+ <ul class="tokens-container list-unstyled">
+ ${FilteredSearchSpecHelper.createInputHTML(placeholder)}
+ </ul>
+ <button class="clear-search" type="button">
+ <i class="fa fa-times"></i>
+ </button>
+ </form>
+ </div>
+ `);
+
+ jest.spyOn(FilteredSearchDropdownManager.prototype, 'setDropdown').mockImplementation();
+ });
+
+ const initializeManager = () => {
+ jest.spyOn(FilteredSearchManager.prototype, 'loadSearchParamsFromURL').mockImplementation();
+ jest.spyOn(FilteredSearchManager.prototype, 'tokenChange').mockImplementation();
+ jest
+ .spyOn(FilteredSearchDropdownManager.prototype, 'updateDropdownOffset')
+ .mockImplementation();
+ jest.spyOn(gl.utils, 'getParameterByName').mockReturnValue(null);
+ jest.spyOn(FilteredSearchVisualTokens, 'unselectTokens');
+
+ input = document.querySelector('.filtered-search');
+ tokensContainer = document.querySelector('.tokens-container');
+ manager = new FilteredSearchManager({ page });
+ manager.setup();
+ };
+
+ afterEach(() => {
+ manager.cleanup();
+ });
+
+ describe('class constructor', () => {
+ const isLocalStorageAvailable = 'isLocalStorageAvailable';
+
+ beforeEach(() => {
+ jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(isLocalStorageAvailable);
+ jest.spyOn(RecentSearchesRoot.prototype, 'render').mockImplementation();
+ });
+
+ it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => {
+ manager = new FilteredSearchManager({ page });
+
+ expect(RecentSearchesService.isAvailable).toHaveBeenCalled();
+ expect(manager.recentSearchesStore.state).toEqual(
+ expect.objectContaining({
+ isLocalStorageAvailable,
+ allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
+ }),
+ );
+ });
+ });
+
+ describe('setup', () => {
+ beforeEach(() => {
+ manager = new FilteredSearchManager({ page });
+ });
+
+ it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => {
+ jest
+ .spyOn(RecentSearchesService.prototype, 'fetch')
+ .mockImplementation(() => Promise.reject(new RecentSearchesServiceError()));
+ jest.spyOn(window, 'Flash').mockImplementation();
+
+ manager.setup();
+
+ expect(window.Flash).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('searchState', () => {
+ beforeEach(() => {
+ jest.spyOn(FilteredSearchManager.prototype, 'search').mockImplementation();
+ initializeManager();
+ });
+
+ it('should blur button', () => {
+ const e = {
+ preventDefault: () => {},
+ currentTarget: {
+ blur: () => {},
+ },
+ };
+ jest.spyOn(e.currentTarget, 'blur');
+ manager.searchState(e);
+
+ expect(e.currentTarget.blur).toHaveBeenCalled();
+ });
+
+ it('should not call search if there is no state', () => {
+ const e = {
+ preventDefault: () => {},
+ currentTarget: {
+ blur: () => {},
+ },
+ };
+
+ manager.searchState(e);
+
+ expect(FilteredSearchManager.prototype.search).not.toHaveBeenCalled();
+ });
+
+ it('should call search when there is state', () => {
+ const e = {
+ preventDefault: () => {},
+ currentTarget: {
+ blur: () => {},
+ dataset: {
+ state: 'opened',
+ },
+ },
+ };
+
+ manager.searchState(e);
+
+ expect(FilteredSearchManager.prototype.search).toHaveBeenCalledWith('opened');
+ });
+ });
+
+ describe('search', () => {
+ const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened';
+
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ it('should search with a single word', done => {
+ input.value = 'searchTerm';
+
+ visitUrl.mockImplementation(url => {
+ expect(url).toEqual(`${defaultParams}&search=searchTerm`);
+ done();
+ });
+
+ manager.search();
+ });
+
+ it('should search with multiple words', done => {
+ input.value = 'awesome search terms';
+
+ visitUrl.mockImplementation(url => {
+ expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`);
+ done();
+ });
+
+ manager.search();
+ });
+
+ it('should search with special characters', done => {
+ input.value = '~!@#$%^&*()_+{}:<>,.?/';
+
+ visitUrl.mockImplementation(url => {
+ expect(url).toEqual(
+ `${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`,
+ );
+ done();
+ });
+
+ manager.search();
+ });
+
+ it('removes duplicated tokens', done => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
+ ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')}
+ ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')}
+ `);
+
+ visitUrl.mockImplementation(url => {
+ expect(url).toEqual(`${defaultParams}&label_name[]=bug`);
+ done();
+ });
+
+ manager.search();
+ });
+ });
+
+ describe('handleInputPlaceholder', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ it('should render placeholder when there is no input', () => {
+ expect(input.placeholder).toEqual(placeholder);
+ });
+
+ it('should not render placeholder when there is input', () => {
+ input.value = 'test words';
+
+ const event = new Event('input');
+ input.dispatchEvent(event);
+
+ expect(input.placeholder).toEqual('');
+ });
+
+ it('should not render placeholder when there are tokens and no input', () => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
+ );
+
+ const event = new Event('input');
+ input.dispatchEvent(event);
+
+ expect(input.placeholder).toEqual('');
+ });
+ });
+
+ describe('checkForBackspace', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ describe('tokens and no input', () => {
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
+ );
+ });
+
+ it('removes last token', () => {
+ jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial');
+ dispatchBackspaceEvent(input, 'keyup');
+ dispatchBackspaceEvent(input, 'keyup');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
+ });
+
+ it('sets the input', () => {
+ jest.spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial');
+ dispatchDeleteEvent(input, 'keyup');
+ dispatchDeleteEvent(input, 'keyup');
+
+ expect(FilteredSearchVisualTokens.getLastTokenPartial).toHaveBeenCalled();
+ expect(input.value).toEqual('~bug');
+ });
+ });
+
+ it('does not remove token or change input when there is existing input', () => {
+ jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial');
+ jest.spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial');
+
+ input.value = 'text';
+ dispatchDeleteEvent(input, 'keyup');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
+ expect(FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled();
+ expect(input.value).toEqual('text');
+ });
+
+ it('does not remove previous token on single backspace press', () => {
+ jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial');
+ jest.spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial');
+
+ input.value = 't';
+ dispatchDeleteEvent(input, 'keyup');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
+ expect(FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled();
+ expect(input.value).toEqual('t');
+ });
+ });
+
+ describe('checkForAltOrCtrlBackspace', () => {
+ beforeEach(() => {
+ initializeManager();
+ jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial');
+ });
+
+ describe('tokens and no input', () => {
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
+ );
+ });
+
+ it('removes last token via alt-backspace', () => {
+ dispatchAltBackspaceEvent(input, 'keydown');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
+ });
+
+ it('removes last token via ctrl-backspace', () => {
+ dispatchCtrlBackspaceEvent(input, 'keydown');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
+ });
+ });
+
+ describe('tokens and input', () => {
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
+ );
+ });
+
+ it('does not remove token or change input via alt-backspace when there is existing input', () => {
+ input = manager.filteredSearchInput;
+ input.value = 'text';
+ dispatchAltBackspaceEvent(input, 'keydown');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
+ expect(input.value).toEqual('text');
+ });
+
+ it('does not remove token or change input via ctrl-backspace when there is existing input', () => {
+ input = manager.filteredSearchInput;
+ input.value = 'text';
+ dispatchCtrlBackspaceEvent(input, 'keydown');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
+ expect(input.value).toEqual('text');
+ });
+ });
+ });
+
+ describe('checkForMetaBackspace', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
+ );
+ });
+
+ it('removes all tokens and input', () => {
+ jest.spyOn(FilteredSearchManager.prototype, 'clearSearch');
+ dispatchMetaBackspaceEvent(input, 'keydown');
+
+ expect(manager.clearSearch).toHaveBeenCalled();
+ expect(manager.filteredSearchInput.value).toEqual('');
+ expect(DropdownUtils.getSearchQuery()).toEqual('');
+ });
+ });
+
+ describe('removeToken', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ it('removes token even when it is already selected', () => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
+ );
+
+ tokensContainer.querySelector('.js-visual-token .remove-token').click();
+
+ expect(tokensContainer.querySelector('.js-visual-token')).toEqual(null);
+ });
+
+ describe('unselected token', () => {
+ beforeEach(() => {
+ jest.spyOn(FilteredSearchManager.prototype, 'removeSelectedToken');
+
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'),
+ );
+ tokensContainer.querySelector('.js-visual-token .remove-token').click();
+ });
+
+ it('removes token when remove button is selected', () => {
+ expect(tokensContainer.querySelector('.js-visual-token')).toEqual(null);
+ });
+
+ it('calls removeSelectedToken', () => {
+ expect(manager.removeSelectedToken).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('removeSelectedTokenKeydown', () => {
+ beforeEach(() => {
+ initializeManager();
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
+ );
+ });
+
+ it('removes selected token when the backspace key is pressed', () => {
+ expect(getVisualTokens().length).toEqual(1);
+
+ dispatchBackspaceEvent(document, 'keydown');
+
+ expect(getVisualTokens().length).toEqual(0);
+ });
+
+ it('removes selected token when the delete key is pressed', () => {
+ expect(getVisualTokens().length).toEqual(1);
+
+ dispatchDeleteEvent(document, 'keydown');
+
+ expect(getVisualTokens().length).toEqual(0);
+ });
+
+ it('updates the input placeholder after removal', () => {
+ manager.handleInputPlaceholder();
+
+ expect(input.placeholder).toEqual('');
+ expect(getVisualTokens().length).toEqual(1);
+
+ dispatchBackspaceEvent(document, 'keydown');
+
+ expect(input.placeholder).not.toEqual('');
+ expect(getVisualTokens().length).toEqual(0);
+ });
+
+ it('updates the clear button after removal', () => {
+ manager.toggleClearSearchButton();
+
+ const clearButton = document.querySelector('.clear-search');
+
+ expect(clearButton.classList.contains('hidden')).toEqual(false);
+ expect(getVisualTokens().length).toEqual(1);
+
+ dispatchBackspaceEvent(document, 'keydown');
+
+ expect(clearButton.classList.contains('hidden')).toEqual(true);
+ expect(getVisualTokens().length).toEqual(0);
+ });
+ });
+
+ describe('removeSelectedToken', () => {
+ beforeEach(() => {
+ jest.spyOn(FilteredSearchVisualTokens, 'removeSelectedToken');
+ jest.spyOn(FilteredSearchManager.prototype, 'handleInputPlaceholder');
+ jest.spyOn(FilteredSearchManager.prototype, 'toggleClearSearchButton');
+ initializeManager();
+ });
+
+ it('calls FilteredSearchVisualTokens.removeSelectedToken', () => {
+ manager.removeSelectedToken();
+
+ expect(FilteredSearchVisualTokens.removeSelectedToken).toHaveBeenCalled();
+ });
+
+ it('calls handleInputPlaceholder', () => {
+ manager.removeSelectedToken();
+
+ expect(manager.handleInputPlaceholder).toHaveBeenCalled();
+ });
+
+ it('calls toggleClearSearchButton', () => {
+ manager.removeSelectedToken();
+
+ expect(manager.toggleClearSearchButton).toHaveBeenCalled();
+ });
+
+ it('calls update dropdown offset', () => {
+ manager.removeSelectedToken();
+
+ expect(manager.dropdownManager.updateDropdownOffset).toHaveBeenCalled();
+ });
+ });
+
+ describe('Clearing search', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ it('Clicking the "x" clear button, clears the input', () => {
+ const inputValue = 'label:=~bug';
+ manager.filteredSearchInput.value = inputValue;
+ manager.filteredSearchInput.dispatchEvent(new Event('input'));
+
+ expect(DropdownUtils.getSearchQuery()).toEqual(inputValue);
+
+ manager.clearSearchButton.click();
+
+ expect(manager.filteredSearchInput.value).toEqual('');
+ expect(DropdownUtils.getSearchQuery()).toEqual('');
+ });
+ });
+
+ describe('toggleInputContainerFocus', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ it('toggles on focus', () => {
+ input.focus();
+
+ expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(
+ true,
+ );
+ });
+
+ it('toggles on blur', () => {
+ input.blur();
+
+ expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(
+ false,
+ );
+ });
+ });
+
+ describe('getAllParams', () => {
+ let paramsArr;
+ beforeEach(() => {
+ paramsArr = ['key=value', 'otherkey=othervalue'];
+
+ initializeManager();
+ });
+
+ it('correctly modifies params when custom modifier is passed', () => {
+ const modifedParams = manager.getAllParams.call(
+ {
+ modifyUrlParams: params => params.reverse(),
+ },
+ [].concat(paramsArr),
+ );
+
+ expect(modifedParams[0]).toBe(paramsArr[1]);
+ });
+
+ it('does not modify params when no custom modifier is passed', () => {
+ const modifedParams = manager.getAllParams.call({}, paramsArr);
+
+ expect(modifedParams[1]).toBe(paramsArr[1]);
+ });
+ });
+});
diff --git a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js b/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js
index dec03e5ab93..dec03e5ab93 100644
--- a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js
+++ b/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js
diff --git a/spec/javascripts/filtered_search/issues_filtered_search_token_keys_spec.js b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js
index c7be900ba2c..c7be900ba2c 100644
--- a/spec/javascripts/filtered_search/issues_filtered_search_token_keys_spec.js
+++ b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js
diff --git a/spec/frontend/filtered_search/recent_searches_root_spec.js b/spec/frontend/filtered_search/recent_searches_root_spec.js
new file mode 100644
index 00000000000..281d406e013
--- /dev/null
+++ b/spec/frontend/filtered_search/recent_searches_root_spec.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import RecentSearchesRoot from '~/filtered_search/recent_searches_root';
+
+jest.mock('vue');
+
+describe('RecentSearchesRoot', () => {
+ describe('render', () => {
+ let recentSearchesRoot;
+ let data;
+ let template;
+
+ beforeEach(() => {
+ recentSearchesRoot = {
+ store: {
+ state: 'state',
+ },
+ };
+
+ Vue.mockImplementation(options => {
+ ({ data, template } = options);
+ });
+
+ RecentSearchesRoot.prototype.render.call(recentSearchesRoot);
+ });
+
+ it('should instantiate Vue', () => {
+ expect(Vue).toHaveBeenCalled();
+ expect(data()).toBe(recentSearchesRoot.store.state);
+ expect(template).toContain(':is-local-storage-available="isLocalStorageAvailable"');
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/services/recent_searches_service_spec.js b/spec/frontend/filtered_search/services/recent_searches_service_spec.js
new file mode 100644
index 00000000000..a89d38b7a20
--- /dev/null
+++ b/spec/frontend/filtered_search/services/recent_searches_service_spec.js
@@ -0,0 +1,161 @@
+import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
+import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error';
+import AccessorUtilities from '~/lib/utils/accessor';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+
+useLocalStorageSpy();
+
+describe('RecentSearchesService', () => {
+ let service;
+
+ beforeEach(() => {
+ service = new RecentSearchesService();
+ localStorage.removeItem(service.localStorageKey);
+ });
+
+ describe('fetch', () => {
+ beforeEach(() => {
+ jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true);
+ });
+
+ it('should default to empty array', done => {
+ const fetchItemsPromise = service.fetch();
+
+ fetchItemsPromise
+ .then(items => {
+ expect(items).toEqual([]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('should reject when unable to parse', done => {
+ jest.spyOn(localStorage, 'getItem').mockReturnValue('fail');
+ const fetchItemsPromise = service.fetch();
+
+ fetchItemsPromise
+ .then(done.fail)
+ .catch(error => {
+ expect(error).toEqual(expect.any(SyntaxError));
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('should reject when service is unavailable', done => {
+ RecentSearchesService.isAvailable.mockReturnValue(false);
+
+ service
+ .fetch()
+ .then(done.fail)
+ .catch(error => {
+ expect(error).toEqual(expect.any(Error));
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('should return items from localStorage', done => {
+ jest.spyOn(localStorage, 'getItem').mockReturnValue('["foo", "bar"]');
+ const fetchItemsPromise = service.fetch();
+
+ fetchItemsPromise
+ .then(items => {
+ expect(items).toEqual(['foo', 'bar']);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ describe('if .isAvailable returns `false`', () => {
+ beforeEach(() => {
+ RecentSearchesService.isAvailable.mockReturnValue(false);
+
+ jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {});
+ });
+
+ it('should not call .getItem', done => {
+ RecentSearchesService.prototype
+ .fetch()
+ .then(done.fail)
+ .catch(err => {
+ expect(err).toEqual(new RecentSearchesServiceError());
+ expect(localStorage.getItem).not.toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+ });
+
+ describe('setRecentSearches', () => {
+ beforeEach(() => {
+ jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true);
+ });
+
+ it('should save things in localStorage', () => {
+ jest.spyOn(localStorage, 'setItem');
+ const items = ['foo', 'bar'];
+ service.save(items);
+
+ expect(localStorage.setItem).toHaveBeenCalledWith(expect.any(String), JSON.stringify(items));
+ });
+ });
+
+ describe('save', () => {
+ beforeEach(() => {
+ jest.spyOn(localStorage, 'setItem');
+ jest.spyOn(RecentSearchesService, 'isAvailable').mockImplementation(() => {});
+ });
+
+ describe('if .isAvailable returns `true`', () => {
+ const searchesString = 'searchesString';
+ const localStorageKey = 'localStorageKey';
+ const recentSearchesService = {
+ localStorageKey,
+ };
+
+ beforeEach(() => {
+ RecentSearchesService.isAvailable.mockReturnValue(true);
+
+ jest.spyOn(JSON, 'stringify').mockReturnValue(searchesString);
+ });
+
+ it('should call .setItem', () => {
+ RecentSearchesService.prototype.save.call(recentSearchesService);
+
+ expect(localStorage.setItem).toHaveBeenCalledWith(localStorageKey, searchesString);
+ });
+ });
+
+ describe('if .isAvailable returns `false`', () => {
+ beforeEach(() => {
+ RecentSearchesService.isAvailable.mockReturnValue(false);
+ });
+
+ it('should not call .setItem', () => {
+ RecentSearchesService.prototype.save();
+
+ expect(localStorage.setItem).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('isAvailable', () => {
+ let isAvailable;
+
+ beforeEach(() => {
+ jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe');
+
+ isAvailable = RecentSearchesService.isAvailable();
+ });
+
+ it('should call .isLocalStorageAccessSafe', () => {
+ expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
+ });
+
+ it('should return a boolean', () => {
+ expect(typeof isAvailable).toBe('boolean');
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js
new file mode 100644
index 00000000000..ea501423403
--- /dev/null
+++ b/spec/frontend/filtered_search/visual_token_value_spec.js
@@ -0,0 +1,389 @@
+import { escape } from 'lodash';
+import VisualTokenValue from '~/filtered_search/visual_token_value';
+import AjaxCache from '~/lib/utils/ajax_cache';
+import UsersCache from '~/lib/utils/users_cache';
+import DropdownUtils from '~/filtered_search//dropdown_utils';
+import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
+
+describe('Filtered Search Visual Tokens', () => {
+ const findElements = tokenElement => {
+ const tokenNameElement = tokenElement.querySelector('.name');
+ const tokenValueContainer = tokenElement.querySelector('.value-container');
+ const tokenValueElement = tokenValueContainer.querySelector('.value');
+ const tokenOperatorElement = tokenElement.querySelector('.operator');
+ const tokenType = tokenNameElement.innerText.toLowerCase();
+ const tokenValue = tokenValueElement.innerText;
+ const tokenOperator = tokenOperatorElement.innerText;
+ const subject = new VisualTokenValue(tokenValue, tokenType, tokenOperator);
+ return { subject, tokenValueContainer, tokenValueElement };
+ };
+
+ let tokensContainer;
+ let authorToken;
+ let bugLabelToken;
+
+ beforeEach(() => {
+ setFixtures(`
+ <ul class="tokens-container">
+ ${FilteredSearchSpecHelper.createInputHTML()}
+ </ul>
+ `);
+ tokensContainer = document.querySelector('.tokens-container');
+
+ authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
+ bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug');
+ });
+
+ describe('updateUserTokenAppearance', () => {
+ let usersCacheSpy;
+
+ beforeEach(() => {
+ jest.spyOn(UsersCache, 'retrieve').mockImplementation(username => usersCacheSpy(username));
+ });
+
+ it('ignores error if UsersCache throws', done => {
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+ const dummyError = new Error('Earth rotated backwards');
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+ const tokenValue = tokenValueElement.innerText;
+ usersCacheSpy = username => {
+ expect(`@${username}`).toBe(tokenValue);
+ return Promise.reject(dummyError);
+ };
+
+ subject
+ .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
+ .then(() => {
+ expect(window.Flash.mock.calls.length).toBe(0);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('does nothing if user cannot be found', done => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+ const tokenValue = tokenValueElement.innerText;
+ usersCacheSpy = username => {
+ expect(`@${username}`).toBe(tokenValue);
+ return Promise.resolve(undefined);
+ };
+
+ subject
+ .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
+ .then(() => {
+ expect(tokenValueElement.innerText).toBe(tokenValue);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('replaces author token with avatar and display name', done => {
+ const dummyUser = {
+ name: 'Important Person',
+ avatar_url: 'https://host.invalid/mypics/avatar.png',
+ };
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+ const tokenValue = tokenValueElement.innerText;
+ usersCacheSpy = username => {
+ expect(`@${username}`).toBe(tokenValue);
+ return Promise.resolve(dummyUser);
+ };
+
+ subject
+ .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
+ .then(() => {
+ expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue);
+ expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
+ const avatar = tokenValueElement.querySelector('img.avatar');
+
+ expect(avatar.getAttribute('src')).toBe(dummyUser.avatar_url);
+ expect(avatar.getAttribute('alt')).toBe('');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('escapes user name when creating token', done => {
+ const dummyUser = {
+ name: '<script>',
+ avatar_url: `${gl.TEST_HOST}/mypics/avatar.png`,
+ };
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+ const tokenValue = tokenValueElement.innerText;
+ usersCacheSpy = username => {
+ expect(`@${username}`).toBe(tokenValue);
+ return Promise.resolve(dummyUser);
+ };
+
+ subject
+ .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
+ .then(() => {
+ expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
+ tokenValueElement.querySelector('.avatar').remove();
+
+ expect(tokenValueElement.innerHTML.trim()).toBe(escape(dummyUser.name));
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('updateLabelTokenColor', () => {
+ const jsonFixtureName = 'labels/project_labels.json';
+ const dummyEndpoint = '/dummy/endpoint';
+
+ preloadFixtures(jsonFixtureName);
+
+ let labelData;
+
+ beforeAll(() => {
+ labelData = getJSONFixture(jsonFixtureName);
+ });
+
+ const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
+ 'label',
+ '=',
+ '~doesnotexist',
+ );
+ const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
+ 'label',
+ '=',
+ '~"some space"',
+ );
+
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
+ ${bugLabelToken.outerHTML}
+ ${missingLabelToken.outerHTML}
+ ${spaceLabelToken.outerHTML}
+ `);
+
+ const filteredSearchInput = document.querySelector('.filtered-search');
+ filteredSearchInput.dataset.runnerTagsEndpoint = `${dummyEndpoint}/admin/runners/tag_list`;
+ filteredSearchInput.dataset.labelsEndpoint = `${dummyEndpoint}/-/labels`;
+ filteredSearchInput.dataset.milestonesEndpoint = `${dummyEndpoint}/-/milestones`;
+
+ AjaxCache.internalStorage = {};
+ AjaxCache.internalStorage[`${filteredSearchInput.dataset.labelsEndpoint}.json`] = labelData;
+ });
+
+ const parseColor = color => {
+ const dummyElement = document.createElement('div');
+ dummyElement.style.color = color;
+ return dummyElement.style.color;
+ };
+
+ const expectValueContainerStyle = (tokenValueContainer, label) => {
+ expect(tokenValueContainer.getAttribute('style')).not.toBe(null);
+ expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color));
+ expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color));
+ };
+
+ const findLabel = tokenValue =>
+ labelData.find(label => tokenValue === `~${DropdownUtils.getEscapedText(label.title)}`);
+
+ it('updates the color of a label token', done => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
+ const tokenValue = tokenValueElement.innerText;
+ const matchingLabel = findLabel(tokenValue);
+
+ subject
+ .updateLabelTokenColor(tokenValueContainer, tokenValue)
+ .then(() => {
+ expectValueContainerStyle(tokenValueContainer, matchingLabel);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('updates the color of a label token with spaces', done => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(spaceLabelToken);
+ const tokenValue = tokenValueElement.innerText;
+ const matchingLabel = findLabel(tokenValue);
+
+ subject
+ .updateLabelTokenColor(tokenValueContainer, tokenValue)
+ .then(() => {
+ expectValueContainerStyle(tokenValueContainer, matchingLabel);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('does not change color of a missing label', done => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(missingLabelToken);
+ const tokenValue = tokenValueElement.innerText;
+ const matchingLabel = findLabel(tokenValue);
+
+ expect(matchingLabel).toBe(undefined);
+
+ subject
+ .updateLabelTokenColor(tokenValueContainer, tokenValue)
+ .then(() => {
+ expect(tokenValueContainer.getAttribute('style')).toBe(null);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('setTokenStyle', () => {
+ let originalTextColor;
+
+ beforeEach(() => {
+ originalTextColor = bugLabelToken.style.color;
+ });
+
+ it('should set backgroundColor', () => {
+ const originalBackgroundColor = bugLabelToken.style.backgroundColor;
+ const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'blue', 'white');
+
+ expect(token.style.backgroundColor).toEqual('blue');
+ expect(token.style.backgroundColor).not.toEqual(originalBackgroundColor);
+ });
+
+ it('should set textColor', () => {
+ const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'white', 'black');
+
+ expect(token.style.color).toEqual('black');
+ expect(token.style.color).not.toEqual(originalTextColor);
+ });
+
+ it('should add inverted class when textColor is #FFFFFF', () => {
+ const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'black', '#FFFFFF');
+
+ expect(token.style.color).toEqual('rgb(255, 255, 255)');
+ expect(token.style.color).not.toEqual(originalTextColor);
+ expect(token.querySelector('.remove-token').classList.contains('inverted')).toEqual(true);
+ });
+ });
+
+ describe('render', () => {
+ const setupSpies = subject => {
+ jest.spyOn(subject, 'updateLabelTokenColor').mockImplementation(() => {});
+ const updateLabelTokenColorSpy = subject.updateLabelTokenColor;
+
+ jest.spyOn(subject, 'updateUserTokenAppearance').mockImplementation(() => {});
+ const updateUserTokenAppearanceSpy = subject.updateUserTokenAppearance;
+
+ return { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy };
+ };
+
+ const keywordToken = FilteredSearchSpecHelper.createFilterVisualToken('search');
+ const milestoneToken = FilteredSearchSpecHelper.createFilterVisualToken(
+ 'milestone',
+ 'upcoming',
+ );
+
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
+ ${authorToken.outerHTML}
+ ${bugLabelToken.outerHTML}
+ ${keywordToken.outerHTML}
+ ${milestoneToken.outerHTML}
+ `);
+ });
+
+ it('renders a author token value element', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+
+ const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(1);
+ const expectedArgs = [tokenValueContainer, tokenValueElement];
+
+ expect(updateUserTokenAppearanceSpy.mock.calls[0]).toEqual(expectedArgs);
+ expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0);
+ });
+
+ it('renders a label token value element', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
+
+ const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateLabelTokenColorSpy.mock.calls.length).toBe(1);
+ const expectedArgs = [tokenValueContainer];
+
+ expect(updateLabelTokenColorSpy.mock.calls[0]).toEqual(expectedArgs);
+ expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0);
+ });
+
+ it('renders a milestone token value element', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(milestoneToken);
+
+ const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0);
+ expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0);
+ });
+
+ it('does not update user token appearance for `none` filter', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+
+ subject.tokenValue = 'none';
+
+ const { updateUserTokenAppearanceSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0);
+ });
+
+ it('does not update user token appearance for `None` filter', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+
+ subject.tokenValue = 'None';
+
+ const { updateUserTokenAppearanceSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0);
+ });
+
+ it('does not update user token appearance for `any` filter', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+
+ subject.tokenValue = 'any';
+
+ const { updateUserTokenAppearanceSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0);
+ });
+
+ it('does not update label token color for `None` filter', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
+
+ subject.tokenValue = 'None';
+
+ const { updateLabelTokenColorSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0);
+ });
+
+ it('does not update label token color for `none` filter', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
+
+ subject.tokenValue = 'none';
+
+ const { updateLabelTokenColorSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0);
+ });
+
+ it('does not update label token color for `any` filter', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
+
+ subject.tokenValue = 'any';
+
+ const { updateLabelTokenColorSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/fixtures/test_report.rb b/spec/frontend/fixtures/test_report.rb
index d26bba9b9d0..d0ecaf11994 100644
--- a/spec/frontend/fixtures/test_report.rb
+++ b/spec/frontend/fixtures/test_report.rb
@@ -15,7 +15,7 @@ describe Projects::PipelinesController, "(JavaScript fixtures)", type: :controll
before do
sign_in(user)
- stub_feature_flags(junit_pipeline_view: true)
+ stub_feature_flags(junit_pipeline_view: project)
end
it "pipelines/test_report.json" do
diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js
new file mode 100644
index 00000000000..fa7c1904339
--- /dev/null
+++ b/spec/frontend/flash_spec.js
@@ -0,0 +1,233 @@
+import flash, { createFlashEl, createAction, hideFlash, removeFlashClickListener } from '~/flash';
+
+describe('Flash', () => {
+ describe('createFlashEl', () => {
+ let el;
+
+ beforeEach(() => {
+ el = document.createElement('div');
+ });
+
+ afterEach(() => {
+ el.innerHTML = '';
+ });
+
+ it('creates flash element with type', () => {
+ el.innerHTML = createFlashEl('testing', 'alert');
+
+ expect(el.querySelector('.flash-alert')).not.toBeNull();
+ });
+
+ it('escapes text', () => {
+ el.innerHTML = createFlashEl('<script>alert("a");</script>', 'alert');
+
+ expect(el.querySelector('.flash-text').textContent.trim()).toBe(
+ '<script>alert("a");</script>',
+ );
+ });
+ });
+
+ describe('hideFlash', () => {
+ let el;
+
+ beforeEach(() => {
+ el = document.createElement('div');
+ el.className = 'js-testing';
+ });
+
+ it('sets transition style', () => {
+ hideFlash(el);
+
+ expect(el.style.transition).toBe('opacity 0.15s');
+ });
+
+ it('sets opacity style', () => {
+ hideFlash(el);
+
+ expect(el.style.opacity).toBe('0');
+ });
+
+ it('does not set styles when fadeTransition is false', () => {
+ hideFlash(el, false);
+
+ expect(el.style.opacity).toBe('');
+ expect(el.style.transition).toBeFalsy();
+ });
+
+ it('removes element after transitionend', () => {
+ document.body.appendChild(el);
+
+ hideFlash(el);
+ el.dispatchEvent(new Event('transitionend'));
+
+ expect(document.querySelector('.js-testing')).toBeNull();
+ });
+
+ it('calls event listener callback once', () => {
+ jest.spyOn(el, 'remove');
+ document.body.appendChild(el);
+
+ hideFlash(el);
+
+ el.dispatchEvent(new Event('transitionend'));
+ el.dispatchEvent(new Event('transitionend'));
+
+ expect(el.remove.mock.calls.length).toBe(1);
+ });
+ });
+
+ describe('createAction', () => {
+ let el;
+
+ beforeEach(() => {
+ el = document.createElement('div');
+ });
+
+ it('creates link with href', () => {
+ el.innerHTML = createAction({
+ href: 'testing',
+ title: 'test',
+ });
+
+ expect(el.querySelector('.flash-action').href).toContain('testing');
+ });
+
+ it('uses hash as href when no href is present', () => {
+ el.innerHTML = createAction({
+ title: 'test',
+ });
+
+ expect(el.querySelector('.flash-action').href).toContain('#');
+ });
+
+ it('adds role when no href is present', () => {
+ el.innerHTML = createAction({
+ title: 'test',
+ });
+
+ expect(el.querySelector('.flash-action').getAttribute('role')).toBe('button');
+ });
+
+ it('escapes the title text', () => {
+ el.innerHTML = createAction({
+ title: '<script>alert("a")</script>',
+ });
+
+ expect(el.querySelector('.flash-action').textContent.trim()).toBe(
+ '<script>alert("a")</script>',
+ );
+ });
+ });
+
+ describe('createFlash', () => {
+ describe('no flash-container', () => {
+ it('does not add to the DOM', () => {
+ const flashEl = flash('testing');
+
+ expect(flashEl).toBeNull();
+
+ expect(document.querySelector('.flash-alert')).toBeNull();
+ });
+ });
+
+ describe('with flash-container', () => {
+ beforeEach(() => {
+ document.body.innerHTML += `
+ <div class="content-wrapper js-content-wrapper">
+ <div class="flash-container"></div>
+ </div>
+ `;
+ });
+
+ afterEach(() => {
+ document.querySelector('.js-content-wrapper').remove();
+ });
+
+ it('adds flash element into container', () => {
+ flash('test', 'alert', document, null, false, true);
+
+ expect(document.querySelector('.flash-alert')).not.toBeNull();
+
+ expect(document.body.className).toContain('flash-shown');
+ });
+
+ it('adds flash into specified parent', () => {
+ flash('test', 'alert', document.querySelector('.content-wrapper'));
+
+ expect(document.querySelector('.content-wrapper .flash-alert')).not.toBeNull();
+ });
+
+ it('adds container classes when inside content-wrapper', () => {
+ flash('test');
+
+ expect(document.querySelector('.flash-text').className).toBe('flash-text');
+ });
+
+ it('does not add container when outside of content-wrapper', () => {
+ document.querySelector('.content-wrapper').className = 'js-content-wrapper';
+ flash('test');
+
+ expect(document.querySelector('.flash-text').className.trim()).toContain('flash-text');
+ });
+
+ it('removes element after clicking', () => {
+ flash('test', 'alert', document, null, false, true);
+
+ document.querySelector('.flash-alert .js-close-icon').click();
+
+ expect(document.querySelector('.flash-alert')).toBeNull();
+
+ expect(document.body.className).not.toContain('flash-shown');
+ });
+
+ describe('with actionConfig', () => {
+ it('adds action link', () => {
+ flash('test', 'alert', document, {
+ title: 'test',
+ });
+
+ expect(document.querySelector('.flash-action')).not.toBeNull();
+ });
+
+ it('calls actionConfig clickHandler on click', () => {
+ const actionConfig = {
+ title: 'test',
+ clickHandler: jest.fn(),
+ };
+
+ flash('test', 'alert', document, actionConfig);
+
+ document.querySelector('.flash-action').click();
+
+ expect(actionConfig.clickHandler).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe('removeFlashClickListener', () => {
+ beforeEach(() => {
+ document.body.innerHTML += `
+ <div class="flash-container">
+ <div class="flash">
+ <div class="close-icon js-close-icon"></div>
+ </div>
+ </div>
+ `;
+ });
+
+ it('removes global flash on click', done => {
+ const flashEl = document.querySelector('.flash');
+
+ removeFlashClickListener(flashEl, false);
+
+ flashEl.querySelector('.js-close-icon').click();
+
+ setImmediate(() => {
+ expect(document.querySelector('.flash')).toBeNull();
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js
new file mode 100644
index 00000000000..7c54a48aa41
--- /dev/null
+++ b/spec/frontend/frequent_items/components/app_spec.js
@@ -0,0 +1,251 @@
+import MockAdapter from 'axios-mock-adapter';
+import Vue from 'vue';
+import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import axios from '~/lib/utils/axios_utils';
+import appComponent from '~/frequent_items/components/app.vue';
+import eventHub from '~/frequent_items/event_hub';
+import store from '~/frequent_items/store';
+import { FREQUENT_ITEMS, HOUR_IN_MS } from '~/frequent_items/constants';
+import { getTopFrequentItems } from '~/frequent_items/utils';
+import { currentSession, mockFrequentProjects, mockSearchedProjects } from '../mock_data';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+useLocalStorageSpy();
+
+let session;
+const createComponentWithStore = (namespace = 'projects') => {
+ session = currentSession[namespace];
+ gon.api_version = session.apiVersion;
+ const Component = Vue.extend(appComponent);
+
+ return mountComponentWithStore(Component, {
+ store,
+ props: {
+ namespace,
+ currentUserName: session.username,
+ currentItem: session.project || session.group,
+ },
+ });
+};
+
+describe('Frequent Items App Component', () => {
+ let vm;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ vm = createComponentWithStore();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ vm.$destroy();
+ });
+
+ describe('methods', () => {
+ describe('dropdownOpenHandler', () => {
+ it('should fetch frequent items when no search has been previously made on desktop', () => {
+ jest.spyOn(vm, 'fetchFrequentItems').mockImplementation(() => {});
+
+ vm.dropdownOpenHandler();
+
+ expect(vm.fetchFrequentItems).toHaveBeenCalledWith();
+ });
+ });
+
+ describe('logItemAccess', () => {
+ let storage;
+
+ beforeEach(() => {
+ storage = {};
+
+ localStorage.setItem.mockImplementation((storageKey, value) => {
+ storage[storageKey] = value;
+ });
+
+ localStorage.getItem.mockImplementation(storageKey => {
+ if (storage[storageKey]) {
+ return storage[storageKey];
+ }
+
+ return null;
+ });
+ });
+
+ it('should create a project store if it does not exist and adds a project', () => {
+ vm.logItemAccess(session.storageKey, session.project);
+
+ const projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects.length).toBe(1);
+ expect(projects[0].frequency).toBe(1);
+ expect(projects[0].lastAccessedOn).toBeDefined();
+ });
+
+ it('should prevent inserting same report multiple times into store', () => {
+ vm.logItemAccess(session.storageKey, session.project);
+ vm.logItemAccess(session.storageKey, session.project);
+
+ const projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects.length).toBe(1);
+ });
+
+ it('should increase frequency of report if it was logged multiple times over the course of an hour', () => {
+ let projects;
+ const newTimestamp = Date.now() + HOUR_IN_MS + 1;
+
+ vm.logItemAccess(session.storageKey, session.project);
+ projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects[0].frequency).toBe(1);
+
+ vm.logItemAccess(session.storageKey, {
+ ...session.project,
+ lastAccessedOn: newTimestamp,
+ });
+ projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects[0].frequency).toBe(2);
+ expect(projects[0].lastAccessedOn).not.toBe(session.project.lastAccessedOn);
+ });
+
+ it('should always update project metadata', () => {
+ let projects;
+ const oldProject = {
+ ...session.project,
+ };
+
+ const newProject = {
+ ...session.project,
+ name: 'New Name',
+ avatarUrl: 'new/avatar.png',
+ namespace: 'New / Namespace',
+ webUrl: 'http://localhost/new/web/url',
+ };
+
+ vm.logItemAccess(session.storageKey, oldProject);
+ projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects[0].name).toBe(oldProject.name);
+ expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl);
+ expect(projects[0].namespace).toBe(oldProject.namespace);
+ expect(projects[0].webUrl).toBe(oldProject.webUrl);
+
+ vm.logItemAccess(session.storageKey, newProject);
+ projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects[0].name).toBe(newProject.name);
+ expect(projects[0].avatarUrl).toBe(newProject.avatarUrl);
+ expect(projects[0].namespace).toBe(newProject.namespace);
+ expect(projects[0].webUrl).toBe(newProject.webUrl);
+ });
+
+ it('should not add more than 20 projects in store', () => {
+ for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT; id += 1) {
+ const project = {
+ ...session.project,
+ id,
+ };
+ vm.logItemAccess(session.storageKey, project);
+ }
+
+ const projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects.length).toBe(FREQUENT_ITEMS.MAX_COUNT);
+ });
+ });
+ });
+
+ describe('created', () => {
+ it('should bind event listeners on eventHub', done => {
+ jest.spyOn(eventHub, '$on').mockImplementation(() => {});
+
+ createComponentWithStore().$mount();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$on).toHaveBeenCalledWith('projects-dropdownOpen', expect.any(Function));
+ done();
+ });
+ });
+ });
+
+ describe('beforeDestroy', () => {
+ it('should unbind event listeners on eventHub', done => {
+ jest.spyOn(eventHub, '$off').mockImplementation(() => {});
+
+ vm.$mount();
+ vm.$destroy();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$off).toHaveBeenCalledWith('projects-dropdownOpen', expect.any(Function));
+ done();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render search input', () => {
+ expect(vm.$el.querySelector('.search-input-container')).toBeDefined();
+ });
+
+ it('should render loading animation', done => {
+ vm.$store.dispatch('fetchSearchedItems');
+
+ Vue.nextTick(() => {
+ const loadingEl = vm.$el.querySelector('.loading-animation');
+
+ expect(loadingEl).toBeDefined();
+ expect(loadingEl.classList.contains('prepend-top-20')).toBe(true);
+ expect(loadingEl.querySelector('span').getAttribute('aria-label')).toBe('Loading projects');
+ done();
+ });
+ });
+
+ it('should render frequent projects list header', done => {
+ Vue.nextTick(() => {
+ const sectionHeaderEl = vm.$el.querySelector('.section-header');
+
+ expect(sectionHeaderEl).toBeDefined();
+ expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited');
+ done();
+ });
+ });
+
+ it('should render frequent projects list', done => {
+ const expectedResult = getTopFrequentItems(mockFrequentProjects);
+ localStorage.getItem.mockImplementation(() => JSON.stringify(mockFrequentProjects));
+
+ expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1);
+
+ vm.fetchFrequentItems();
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(
+ expectedResult.length,
+ );
+ done();
+ });
+ });
+
+ it('should render searched projects list', done => {
+ mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects);
+
+ expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1);
+
+ vm.$store.dispatch('setSearchQuery', 'gitlab');
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
+ })
+ .then(waitForPromises)
+ .then(() => {
+ expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(
+ mockSearchedProjects.data.length,
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/frequent_items/mock_data.js b/spec/frontend/frequent_items/mock_data.js
index 5cd4cddd877..8c3c66f67ff 100644
--- a/spec/frontend/frequent_items/mock_data.js
+++ b/spec/frontend/frequent_items/mock_data.js
@@ -1,5 +1,94 @@
import { TEST_HOST } from 'helpers/test_constants';
+export const currentSession = {
+ groups: {
+ username: 'root',
+ storageKey: 'root/frequent-groups',
+ apiVersion: 'v4',
+ group: {
+ id: 1,
+ name: 'dummy-group',
+ full_name: 'dummy-parent-group',
+ webUrl: `${TEST_HOST}/dummy-group`,
+ avatarUrl: null,
+ lastAccessedOn: Date.now(),
+ },
+ },
+ projects: {
+ username: 'root',
+ storageKey: 'root/frequent-projects',
+ apiVersion: 'v4',
+ project: {
+ id: 1,
+ name: 'dummy-project',
+ namespace: 'SampleGroup / Dummy-Project',
+ webUrl: `${TEST_HOST}/samplegroup/dummy-project`,
+ avatarUrl: null,
+ lastAccessedOn: Date.now(),
+ },
+ },
+};
+
+export const mockNamespace = 'projects';
+
+export const mockStorageKey = 'test-user/frequent-projects';
+
+export const mockGroup = {
+ id: 1,
+ name: 'Sub451',
+ namespace: 'Commit451 / Sub451',
+ webUrl: `${TEST_HOST}/Commit451/Sub451`,
+ avatarUrl: null,
+};
+
+export const mockRawGroup = {
+ id: 1,
+ name: 'Sub451',
+ full_name: 'Commit451 / Sub451',
+ web_url: `${TEST_HOST}/Commit451/Sub451`,
+ avatar_url: null,
+};
+
+export const mockFrequentGroups = [
+ {
+ id: 3,
+ name: 'Subgroup451',
+ full_name: 'Commit451 / Subgroup451',
+ webUrl: '/Commit451/Subgroup451',
+ avatarUrl: null,
+ frequency: 7,
+ lastAccessedOn: 1497979281815,
+ },
+ {
+ id: 1,
+ name: 'Commit451',
+ full_name: 'Commit451',
+ webUrl: '/Commit451',
+ avatarUrl: null,
+ frequency: 3,
+ lastAccessedOn: 1497979281815,
+ },
+];
+
+export const mockSearchedGroups = [mockRawGroup];
+export const mockProcessedSearchedGroups = [mockGroup];
+
+export const mockProject = {
+ id: 1,
+ name: 'GitLab Community Edition',
+ namespace: 'gitlab-org / gitlab-ce',
+ webUrl: `${TEST_HOST}/gitlab-org/gitlab-foss`,
+ avatarUrl: null,
+};
+
+export const mockRawProject = {
+ id: 1,
+ name: 'GitLab Community Edition',
+ name_with_namespace: 'gitlab-org / gitlab-ce',
+ web_url: `${TEST_HOST}/gitlab-org/gitlab-foss`,
+ avatar_url: null,
+};
+
export const mockFrequentProjects = [
{
id: 1,
@@ -48,10 +137,34 @@ export const mockFrequentProjects = [
},
];
-export const mockProject = {
- id: 1,
- name: 'GitLab Community Edition',
- namespace: 'gitlab-org / gitlab-ce',
- webUrl: `${TEST_HOST}/gitlab-org/gitlab-foss`,
- avatarUrl: null,
-};
+export const mockSearchedProjects = { data: [mockRawProject] };
+export const mockProcessedSearchedProjects = [mockProject];
+
+export const unsortedFrequentItems = [
+ { id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
+ { id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
+ { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
+ { id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
+ { id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
+ { id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
+ { id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
+ { id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
+ { id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
+];
+
+/**
+ * This const has a specific order which tests authenticity
+ * of `getTopFrequentItems` method so
+ * DO NOT change order of items in this const.
+ */
+export const sortedFrequentItems = [
+ { id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
+ { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
+ { id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
+ { id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
+ { id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
+ { id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
+ { id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
+ { id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
+ { id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
+];
diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js
new file mode 100644
index 00000000000..304098e85f1
--- /dev/null
+++ b/spec/frontend/frequent_items/store/actions_spec.js
@@ -0,0 +1,228 @@
+import testAction from 'helpers/vuex_action_helper';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import AccessorUtilities from '~/lib/utils/accessor';
+import * as actions from '~/frequent_items/store/actions';
+import * as types from '~/frequent_items/store/mutation_types';
+import state from '~/frequent_items/store/state';
+import {
+ mockNamespace,
+ mockStorageKey,
+ mockFrequentProjects,
+ mockSearchedProjects,
+} from '../mock_data';
+
+describe('Frequent Items Dropdown Store Actions', () => {
+ let mockedState;
+ let mock;
+
+ beforeEach(() => {
+ mockedState = state();
+ mock = new MockAdapter(axios);
+
+ mockedState.namespace = mockNamespace;
+ mockedState.storageKey = mockStorageKey;
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('setNamespace', () => {
+ it('should set namespace', done => {
+ testAction(
+ actions.setNamespace,
+ mockNamespace,
+ mockedState,
+ [{ type: types.SET_NAMESPACE, payload: mockNamespace }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setStorageKey', () => {
+ it('should set storage key', done => {
+ testAction(
+ actions.setStorageKey,
+ mockStorageKey,
+ mockedState,
+ [{ type: types.SET_STORAGE_KEY, payload: mockStorageKey }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestFrequentItems', () => {
+ it('should request frequent items', done => {
+ testAction(
+ actions.requestFrequentItems,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_FREQUENT_ITEMS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveFrequentItemsSuccess', () => {
+ it('should set frequent items', done => {
+ testAction(
+ actions.receiveFrequentItemsSuccess,
+ mockFrequentProjects,
+ mockedState,
+ [{ type: types.RECEIVE_FREQUENT_ITEMS_SUCCESS, payload: mockFrequentProjects }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveFrequentItemsError', () => {
+ it('should set frequent items error state', done => {
+ testAction(
+ actions.receiveFrequentItemsError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_FREQUENT_ITEMS_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchFrequentItems', () => {
+ it('should dispatch `receiveFrequentItemsSuccess`', done => {
+ mockedState.namespace = mockNamespace;
+ mockedState.storageKey = mockStorageKey;
+
+ testAction(
+ actions.fetchFrequentItems,
+ null,
+ mockedState,
+ [],
+ [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsSuccess', payload: [] }],
+ done,
+ );
+ });
+
+ it('should dispatch `receiveFrequentItemsError`', done => {
+ jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(false);
+ mockedState.namespace = mockNamespace;
+ mockedState.storageKey = mockStorageKey;
+
+ testAction(
+ actions.fetchFrequentItems,
+ null,
+ mockedState,
+ [],
+ [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsError' }],
+ done,
+ );
+ });
+ });
+
+ describe('requestSearchedItems', () => {
+ it('should request searched items', done => {
+ testAction(
+ actions.requestSearchedItems,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_SEARCHED_ITEMS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveSearchedItemsSuccess', () => {
+ it('should set searched items', done => {
+ testAction(
+ actions.receiveSearchedItemsSuccess,
+ mockSearchedProjects,
+ mockedState,
+ [{ type: types.RECEIVE_SEARCHED_ITEMS_SUCCESS, payload: mockSearchedProjects }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveSearchedItemsError', () => {
+ it('should set searched items error state', done => {
+ testAction(
+ actions.receiveSearchedItemsError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_SEARCHED_ITEMS_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchSearchedItems', () => {
+ beforeEach(() => {
+ gon.api_version = 'v4';
+ });
+
+ it('should dispatch `receiveSearchedItemsSuccess`', done => {
+ mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects, {});
+
+ testAction(
+ actions.fetchSearchedItems,
+ null,
+ mockedState,
+ [],
+ [
+ { type: 'requestSearchedItems' },
+ {
+ type: 'receiveSearchedItemsSuccess',
+ payload: { data: mockSearchedProjects, headers: {} },
+ },
+ ],
+ done,
+ );
+ });
+
+ it('should dispatch `receiveSearchedItemsError`', done => {
+ gon.api_version = 'v4';
+ mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(500);
+
+ testAction(
+ actions.fetchSearchedItems,
+ null,
+ mockedState,
+ [],
+ [{ type: 'requestSearchedItems' }, { type: 'receiveSearchedItemsError' }],
+ done,
+ );
+ });
+ });
+
+ describe('setSearchQuery', () => {
+ it('should commit query and dispatch `fetchSearchedItems` when query is present', done => {
+ testAction(
+ actions.setSearchQuery,
+ { query: 'test' },
+ mockedState,
+ [{ type: types.SET_SEARCH_QUERY, payload: { query: 'test' } }],
+ [{ type: 'fetchSearchedItems', payload: { query: 'test' } }],
+ done,
+ );
+ });
+
+ it('should commit query and dispatch `fetchFrequentItems` when query is empty', done => {
+ testAction(
+ actions.setSearchQuery,
+ null,
+ mockedState,
+ [{ type: types.SET_SEARCH_QUERY, payload: null }],
+ [{ type: 'fetchFrequentItems' }],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/javascripts/frequent_items/store/mutations_spec.js b/spec/frontend/frequent_items/store/mutations_spec.js
index d36964b2600..d36964b2600 100644
--- a/spec/javascripts/frequent_items/store/mutations_spec.js
+++ b/spec/frontend/frequent_items/store/mutations_spec.js
diff --git a/spec/frontend/frequent_items/utils_spec.js b/spec/frontend/frequent_items/utils_spec.js
new file mode 100644
index 00000000000..181dd9268dc
--- /dev/null
+++ b/spec/frontend/frequent_items/utils_spec.js
@@ -0,0 +1,130 @@
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import {
+ isMobile,
+ getTopFrequentItems,
+ updateExistingFrequentItem,
+ sanitizeItem,
+} from '~/frequent_items/utils';
+import { HOUR_IN_MS, FREQUENT_ITEMS } from '~/frequent_items/constants';
+import { mockProject, unsortedFrequentItems, sortedFrequentItems } from './mock_data';
+
+describe('Frequent Items utils spec', () => {
+ describe('isMobile', () => {
+ it('returns true when the screen is medium ', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md');
+
+ expect(isMobile()).toBe(true);
+ });
+
+ it('returns true when the screen is small ', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('sm');
+
+ expect(isMobile()).toBe(true);
+ });
+
+ it('returns true when the screen is extra-small ', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xs');
+
+ expect(isMobile()).toBe(true);
+ });
+
+ it('returns false when the screen is larger than medium ', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('lg');
+
+ expect(isMobile()).toBe(false);
+ });
+ });
+
+ describe('getTopFrequentItems', () => {
+ it('returns empty array if no items provided', () => {
+ const result = getTopFrequentItems();
+
+ expect(result.length).toBe(0);
+ });
+
+ it('returns correct amount of items for mobile', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md');
+ const result = getTopFrequentItems(unsortedFrequentItems);
+
+ expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_MOBILE);
+ });
+
+ it('returns correct amount of items for desktop', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl');
+ const result = getTopFrequentItems(unsortedFrequentItems);
+
+ expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_DESKTOP);
+ });
+
+ it('sorts frequent items in order of frequency and lastAccessedOn', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl');
+ const result = getTopFrequentItems(unsortedFrequentItems);
+ const expectedResult = sortedFrequentItems.slice(0, FREQUENT_ITEMS.LIST_COUNT_DESKTOP);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('updateExistingFrequentItem', () => {
+ let mockedProject;
+
+ beforeEach(() => {
+ mockedProject = {
+ ...mockProject,
+ frequency: 1,
+ lastAccessedOn: 1497979281815,
+ };
+ });
+
+ it('updates item if accessed over an hour ago', () => {
+ const newTimestamp = Date.now() + HOUR_IN_MS + 1;
+ const newItem = {
+ ...mockedProject,
+ lastAccessedOn: newTimestamp,
+ };
+ const result = updateExistingFrequentItem(mockedProject, newItem);
+
+ expect(result.frequency).toBe(mockedProject.frequency + 1);
+ });
+
+ it('does not update item if accessed within the hour', () => {
+ const newItem = {
+ ...mockedProject,
+ lastAccessedOn: mockedProject.lastAccessedOn + HOUR_IN_MS,
+ };
+ const result = updateExistingFrequentItem(mockedProject, newItem);
+
+ expect(result.frequency).toBe(mockedProject.frequency);
+ });
+ });
+
+ describe('sanitizeItem', () => {
+ it('strips HTML tags for name and namespace', () => {
+ const input = {
+ name: '<br><b>test</b>',
+ namespace: '<br>test',
+ id: 1,
+ };
+
+ expect(sanitizeItem(input)).toEqual({ name: 'test', namespace: 'test', id: 1 });
+ });
+
+ it("skips `name` key if it doesn't exist on the item", () => {
+ const input = {
+ namespace: '<br>test',
+ id: 1,
+ };
+
+ expect(sanitizeItem(input)).toEqual({ namespace: 'test', id: 1 });
+ });
+
+ it("skips `namespace` key if it doesn't exist on the item", () => {
+ const input = {
+ name: '<br><b>test</b>',
+ id: 1,
+ };
+
+ expect(sanitizeItem(input)).toEqual({ name: 'test', id: 1 });
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js
new file mode 100644
index 00000000000..35eda21e047
--- /dev/null
+++ b/spec/frontend/groups/components/app_spec.js
@@ -0,0 +1,507 @@
+import '~/flash';
+import $ from 'jquery';
+import Vue from 'vue';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import appComponent from '~/groups/components/app.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import eventHub from '~/groups/event_hub';
+import GroupsStore from '~/groups/store/groups_store';
+import GroupsService from '~/groups/service/groups_service';
+import * as urlUtilities from '~/lib/utils/url_utility';
+
+import {
+ mockEndpoint,
+ mockGroups,
+ mockSearchedGroups,
+ mockRawPageInfo,
+ mockParentGroupItem,
+ mockRawChildren,
+ mockChildren,
+ mockPageInfo,
+} from '../mock_data';
+
+const createComponent = (hideProjects = false) => {
+ const Component = Vue.extend(appComponent);
+ const store = new GroupsStore(false);
+ const service = new GroupsService(mockEndpoint);
+
+ store.state.pageInfo = mockPageInfo;
+
+ return new Component({
+ propsData: {
+ store,
+ service,
+ hideProjects,
+ },
+ });
+};
+
+describe('AppComponent', () => {
+ let vm;
+ let mock;
+ let getGroupsSpy;
+
+ beforeEach(() => {
+ mock = new AxiosMockAdapter(axios);
+ mock.onGet('/dashboard/groups.json').reply(200, mockGroups);
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+ getGroupsSpy = jest.spyOn(vm.service, 'getGroups');
+ return vm.$nextTick();
+ });
+
+ describe('computed', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('groups', () => {
+ it('should return list of groups from store', () => {
+ jest.spyOn(vm.store, 'getGroups').mockImplementation(() => {});
+
+ const { groups } = vm;
+
+ expect(vm.store.getGroups).toHaveBeenCalled();
+ expect(groups).not.toBeDefined();
+ });
+ });
+
+ describe('pageInfo', () => {
+ it('should return pagination info from store', () => {
+ jest.spyOn(vm.store, 'getPaginationInfo').mockImplementation(() => {});
+
+ const { pageInfo } = vm;
+
+ expect(vm.store.getPaginationInfo).toHaveBeenCalled();
+ expect(pageInfo).not.toBeDefined();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('fetchGroups', () => {
+ it('should call `getGroups` with all the params provided', () => {
+ return vm
+ .fetchGroups({
+ parentId: 1,
+ page: 2,
+ filterGroupsBy: 'git',
+ sortBy: 'created_desc',
+ archived: true,
+ })
+ .then(() => {
+ expect(getGroupsSpy).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true);
+ });
+ });
+
+ it('should set headers to store for building pagination info when called with `updatePagination`', () => {
+ mock.onGet('/dashboard/groups.json').reply(200, { headers: mockRawPageInfo });
+
+ jest.spyOn(vm, 'updatePagination').mockImplementation(() => {});
+
+ return vm.fetchGroups({ updatePagination: true }).then(() => {
+ expect(getGroupsSpy).toHaveBeenCalled();
+ expect(vm.updatePagination).toHaveBeenCalled();
+ });
+ });
+
+ it('should show flash error when request fails', () => {
+ mock.onGet('/dashboard/groups.json').reply(400);
+
+ jest.spyOn($, 'scrollTo').mockImplementation(() => {});
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+
+ return vm.fetchGroups({}).then(() => {
+ expect(vm.isLoading).toBe(false);
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.');
+ });
+ });
+ });
+
+ describe('fetchAllGroups', () => {
+ beforeEach(() => {
+ jest.spyOn(vm, 'fetchGroups');
+ jest.spyOn(vm, 'updateGroups');
+ });
+
+ it('should fetch default set of groups', () => {
+ jest.spyOn(vm, 'updatePagination');
+
+ const fetchPromise = vm.fetchAllGroups();
+
+ expect(vm.isLoading).toBe(true);
+
+ return fetchPromise.then(() => {
+ expect(vm.isLoading).toBe(false);
+ expect(vm.updateGroups).toHaveBeenCalled();
+ });
+ });
+
+ it('should fetch matching set of groups when app is loaded with search query', () => {
+ mock.onGet('/dashboard/groups.json').reply(200, mockSearchedGroups);
+
+ const fetchPromise = vm.fetchAllGroups();
+
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ page: null,
+ filterGroupsBy: null,
+ sortBy: null,
+ updatePagination: true,
+ archived: null,
+ });
+ return fetchPromise.then(() => {
+ expect(vm.updateGroups).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('fetchPage', () => {
+ beforeEach(() => {
+ jest.spyOn(vm, 'fetchGroups');
+ jest.spyOn(vm, 'updateGroups');
+ });
+
+ it('should fetch groups for provided page details and update window state', () => {
+ jest.spyOn(urlUtilities, 'mergeUrlParams');
+ jest.spyOn(window.history, 'replaceState').mockImplementation(() => {});
+ jest.spyOn($, 'scrollTo').mockImplementation(() => {});
+
+ const fetchPagePromise = vm.fetchPage(2, null, null, true);
+
+ expect(vm.isLoading).toBe(true);
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ page: 2,
+ filterGroupsBy: null,
+ sortBy: null,
+ updatePagination: true,
+ archived: true,
+ });
+
+ return fetchPagePromise.then(() => {
+ expect(vm.isLoading).toBe(false);
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(urlUtilities.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, expect.any(String));
+ expect(window.history.replaceState).toHaveBeenCalledWith(
+ {
+ page: expect.any(String),
+ },
+ expect.any(String),
+ expect.any(String),
+ );
+
+ expect(vm.updateGroups).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('toggleChildren', () => {
+ let groupItem;
+
+ beforeEach(() => {
+ groupItem = { ...mockParentGroupItem };
+ groupItem.isOpen = false;
+ groupItem.isChildrenLoading = false;
+ });
+
+ it('should fetch children of given group and expand it if group is collapsed and children are not loaded', () => {
+ mock.onGet('/dashboard/groups.json').reply(200, mockRawChildren);
+ jest.spyOn(vm, 'fetchGroups');
+ jest.spyOn(vm.store, 'setGroupChildren').mockImplementation(() => {});
+
+ vm.toggleChildren(groupItem);
+
+ expect(groupItem.isChildrenLoading).toBe(true);
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ parentId: groupItem.id,
+ });
+ return waitForPromises().then(() => {
+ expect(vm.store.setGroupChildren).toHaveBeenCalled();
+ });
+ });
+
+ it('should skip network request while expanding group if children are already loaded', () => {
+ jest.spyOn(vm, 'fetchGroups');
+ groupItem.children = mockRawChildren;
+
+ vm.toggleChildren(groupItem);
+
+ expect(vm.fetchGroups).not.toHaveBeenCalled();
+ expect(groupItem.isOpen).toBe(true);
+ });
+
+ it('should collapse group if it is already expanded', () => {
+ jest.spyOn(vm, 'fetchGroups');
+ groupItem.isOpen = true;
+
+ vm.toggleChildren(groupItem);
+
+ expect(vm.fetchGroups).not.toHaveBeenCalled();
+ expect(groupItem.isOpen).toBe(false);
+ });
+
+ it('should set `isChildrenLoading` back to `false` if load request fails', () => {
+ mock.onGet('/dashboard/groups.json').reply(400);
+
+ vm.toggleChildren(groupItem);
+
+ expect(groupItem.isChildrenLoading).toBe(true);
+ return waitForPromises().then(() => {
+ expect(groupItem.isChildrenLoading).toBe(false);
+ });
+ });
+ });
+
+ describe('showLeaveGroupModal', () => {
+ it('caches candidate group (as props) which is to be left', () => {
+ const group = { ...mockParentGroupItem };
+
+ expect(vm.targetGroup).toBe(null);
+ expect(vm.targetParentGroup).toBe(null);
+ vm.showLeaveGroupModal(group, mockParentGroupItem);
+
+ expect(vm.targetGroup).not.toBe(null);
+ expect(vm.targetParentGroup).not.toBe(null);
+ });
+
+ it('updates props which show modal confirmation dialog', () => {
+ const group = { ...mockParentGroupItem };
+
+ expect(vm.showModal).toBe(false);
+ expect(vm.groupLeaveConfirmationMessage).toBe('');
+ vm.showLeaveGroupModal(group, mockParentGroupItem);
+
+ expect(vm.showModal).toBe(true);
+ expect(vm.groupLeaveConfirmationMessage).toBe(
+ `Are you sure you want to leave the "${group.fullName}" group?`,
+ );
+ });
+ });
+
+ describe('hideLeaveGroupModal', () => {
+ it('hides modal confirmation which is shown before leaving the group', () => {
+ const group = { ...mockParentGroupItem };
+ vm.showLeaveGroupModal(group, mockParentGroupItem);
+
+ expect(vm.showModal).toBe(true);
+ vm.hideLeaveGroupModal();
+
+ expect(vm.showModal).toBe(false);
+ });
+ });
+
+ describe('leaveGroup', () => {
+ let groupItem;
+ let childGroupItem;
+
+ beforeEach(() => {
+ groupItem = { ...mockParentGroupItem };
+ groupItem.children = mockChildren;
+ [childGroupItem] = groupItem.children;
+ groupItem.isChildrenLoading = false;
+ vm.targetGroup = childGroupItem;
+ vm.targetParentGroup = groupItem;
+ });
+
+ it('hides modal confirmation leave group and remove group item from tree', () => {
+ const notice = `You left the "${childGroupItem.fullName}" group.`;
+ jest.spyOn(vm.service, 'leaveGroup').mockResolvedValue({ data: { notice } });
+ jest.spyOn(vm.store, 'removeGroup');
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+ jest.spyOn($, 'scrollTo').mockImplementation(() => {});
+
+ vm.leaveGroup();
+
+ expect(vm.showModal).toBe(false);
+ expect(vm.targetGroup.isBeingRemoved).toBe(true);
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(vm.targetGroup.leavePath);
+ return waitForPromises().then(() => {
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(vm.store.removeGroup).toHaveBeenCalledWith(vm.targetGroup, vm.targetParentGroup);
+ expect(window.Flash).toHaveBeenCalledWith(notice, 'notice');
+ });
+ });
+
+ it('should show error flash message if request failed to leave group', () => {
+ const message = 'An error occurred. Please try again.';
+ jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 500 });
+ jest.spyOn(vm.store, 'removeGroup');
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+
+ vm.leaveGroup();
+
+ expect(vm.targetGroup.isBeingRemoved).toBe(true);
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ return waitForPromises().then(() => {
+ expect(vm.store.removeGroup).not.toHaveBeenCalled();
+ expect(window.Flash).toHaveBeenCalledWith(message);
+ expect(vm.targetGroup.isBeingRemoved).toBe(false);
+ });
+ });
+
+ it('should show appropriate error flash message if request forbids to leave group', () => {
+ const message = 'Failed to leave the group. Please make sure you are not the only owner.';
+ jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 403 });
+ jest.spyOn(vm.store, 'removeGroup');
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+
+ vm.leaveGroup(childGroupItem, groupItem);
+
+ expect(vm.targetGroup.isBeingRemoved).toBe(true);
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ return waitForPromises().then(() => {
+ expect(vm.store.removeGroup).not.toHaveBeenCalled();
+ expect(window.Flash).toHaveBeenCalledWith(message);
+ expect(vm.targetGroup.isBeingRemoved).toBe(false);
+ });
+ });
+ });
+
+ describe('updatePagination', () => {
+ it('should set pagination info to store from provided headers', () => {
+ jest.spyOn(vm.store, 'setPaginationInfo').mockImplementation(() => {});
+
+ vm.updatePagination(mockRawPageInfo);
+
+ expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo);
+ });
+ });
+
+ describe('updateGroups', () => {
+ it('should call setGroups on store if method was called directly', () => {
+ jest.spyOn(vm.store, 'setGroups').mockImplementation(() => {});
+
+ vm.updateGroups(mockGroups);
+
+ expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups);
+ });
+
+ it('should call setSearchedGroups on store if method was called with fromSearch param', () => {
+ jest.spyOn(vm.store, 'setSearchedGroups').mockImplementation(() => {});
+
+ vm.updateGroups(mockGroups, true);
+
+ expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups);
+ });
+
+ it('should set `isSearchEmpty` prop based on groups count', () => {
+ vm.updateGroups(mockGroups);
+
+ expect(vm.isSearchEmpty).toBe(false);
+
+ vm.updateGroups([]);
+
+ expect(vm.isSearchEmpty).toBe(true);
+ });
+ });
+ });
+
+ describe('created', () => {
+ it('should bind event listeners on eventHub', () => {
+ jest.spyOn(eventHub, '$on').mockImplementation(() => {});
+
+ const newVm = createComponent();
+ newVm.$mount();
+
+ return vm.$nextTick().then(() => {
+ expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', expect.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', expect.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', expect.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', expect.any(Function));
+ newVm.$destroy();
+ });
+ });
+
+ it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', () => {
+ const newVm = createComponent();
+ newVm.$mount();
+ return vm.$nextTick().then(() => {
+ expect(newVm.searchEmptyMessage).toBe('No groups or projects matched your search');
+ newVm.$destroy();
+ });
+ });
+
+ it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', () => {
+ const newVm = createComponent(true);
+ newVm.$mount();
+ return vm.$nextTick().then(() => {
+ expect(newVm.searchEmptyMessage).toBe('No groups matched your search');
+ newVm.$destroy();
+ });
+ });
+ });
+
+ describe('beforeDestroy', () => {
+ it('should unbind event listeners on eventHub', () => {
+ jest.spyOn(eventHub, '$off').mockImplementation(() => {});
+
+ const newVm = createComponent();
+ newVm.$mount();
+ newVm.$destroy();
+
+ return vm.$nextTick().then(() => {
+ expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', expect.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', expect.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', expect.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', expect.any(Function));
+ });
+ });
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render loading icon', () => {
+ vm.isLoading = true;
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
+ expect(vm.$el.querySelector('span').getAttribute('aria-label')).toBe('Loading groups');
+ });
+ });
+
+ it('should render groups tree', () => {
+ vm.store.state.groups = [mockParentGroupItem];
+ vm.isLoading = false;
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+ });
+ });
+
+ it('renders modal confirmation dialog', () => {
+ vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?';
+ vm.showModal = true;
+ return vm.$nextTick().then(() => {
+ const modalDialogEl = vm.$el.querySelector('.modal');
+
+ expect(modalDialogEl).not.toBe(null);
+ expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?');
+ expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/group_folder_spec.js b/spec/frontend/groups/components/group_folder_spec.js
new file mode 100644
index 00000000000..a40fa9bece8
--- /dev/null
+++ b/spec/frontend/groups/components/group_folder_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import { mockGroups, mockParentGroupItem } from '../mock_data';
+
+const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => {
+ const Component = Vue.extend(groupFolderComponent);
+
+ return new Component({
+ propsData: {
+ groups,
+ parentGroup,
+ },
+ });
+};
+
+describe('GroupFolderComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+ vm.$mount();
+
+ return Vue.nextTick();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('hasMoreChildren', () => {
+ it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => {
+ expect(vm.hasMoreChildren).toBeFalsy();
+ });
+ });
+
+ describe('moreChildrenStats', () => {
+ it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => {
+ expect(vm.moreChildrenStats).toBe('3 more items');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7);
+ });
+
+ it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => {
+ const parentGroup = { ...mockParentGroupItem };
+ parentGroup.childrenCount = 21;
+
+ const newVm = createComponent(mockGroups, parentGroup);
+ newVm.$mount();
+
+ expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined();
+ newVm.$destroy();
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js
new file mode 100644
index 00000000000..7eb1c54ddb2
--- /dev/null
+++ b/spec/frontend/groups/components/group_item_spec.js
@@ -0,0 +1,215 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import eventHub from '~/groups/event_hub';
+import * as urlUtilities from '~/lib/utils/url_utility';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+ const Component = Vue.extend(groupItemComponent);
+
+ return mountComponent(Component, {
+ group,
+ parentGroup,
+ });
+};
+
+describe('GroupItemComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ Vue.component('group-folder', groupFolderComponent);
+
+ vm = createComponent();
+
+ return Vue.nextTick();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('groupDomId', () => {
+ it('should return ID string suffixed with group ID', () => {
+ expect(vm.groupDomId).toBe('group-55');
+ });
+ });
+
+ describe('rowClass', () => {
+ it('should return map of classes based on group details', () => {
+ const classes = ['is-open', 'has-children', 'has-description', 'being-removed'];
+ const { rowClass } = vm;
+
+ expect(Object.keys(rowClass).length).toBe(classes.length);
+ Object.keys(rowClass).forEach(className => {
+ expect(classes.indexOf(className)).toBeGreaterThan(-1);
+ });
+ });
+ });
+
+ describe('hasChildren', () => {
+ it('should return boolean value representing if group has any children present', () => {
+ let newVm;
+ const group = { ...mockParentGroupItem };
+
+ group.childrenCount = 5;
+ newVm = createComponent(group);
+
+ expect(newVm.hasChildren).toBeTruthy();
+ newVm.$destroy();
+
+ group.childrenCount = 0;
+ newVm = createComponent(group);
+
+ expect(newVm.hasChildren).toBeFalsy();
+ newVm.$destroy();
+ });
+ });
+
+ describe('hasAvatar', () => {
+ it('should return boolean value representing if group has any avatar present', () => {
+ let newVm;
+ const group = { ...mockParentGroupItem };
+
+ group.avatarUrl = null;
+ newVm = createComponent(group);
+
+ expect(newVm.hasAvatar).toBeFalsy();
+ newVm.$destroy();
+
+ group.avatarUrl = '/uploads/group_avatar.png';
+ newVm = createComponent(group);
+
+ expect(newVm.hasAvatar).toBeTruthy();
+ newVm.$destroy();
+ });
+ });
+
+ describe('isGroup', () => {
+ it('should return boolean value representing if group item is of type `group` or not', () => {
+ let newVm;
+ const group = { ...mockParentGroupItem };
+
+ group.type = 'group';
+ newVm = createComponent(group);
+
+ expect(newVm.isGroup).toBeTruthy();
+ newVm.$destroy();
+
+ group.type = 'project';
+ newVm = createComponent(group);
+
+ expect(newVm.isGroup).toBeFalsy();
+ newVm.$destroy();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('onClickRowGroup', () => {
+ let event;
+
+ beforeEach(() => {
+ const classList = {
+ contains() {
+ return false;
+ },
+ };
+
+ event = {
+ target: {
+ classList,
+ parentElement: {
+ classList,
+ },
+ },
+ };
+ });
+
+ it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+
+ vm.onClickRowGroup(event);
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group);
+ });
+
+ it('should navigate page to group homepage if group does not have any children present', () => {
+ jest.spyOn(urlUtilities, 'visitUrl').mockImplementation();
+ const group = { ...mockParentGroupItem };
+ group.childrenCount = 0;
+ const newVm = createComponent(group);
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+
+ newVm.onClickRowGroup(event);
+
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ expect(urlUtilities.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath);
+ });
+ });
+ });
+
+ describe('template', () => {
+ let group = null;
+
+ describe('for a group pending deletion', () => {
+ beforeEach(() => {
+ group = { ...mockParentGroupItem, pendingRemoval: true };
+ vm = createComponent(group);
+ });
+
+ it('renders the group pending removal badge', () => {
+ const badgeEl = vm.$el.querySelector('.badge-warning');
+
+ expect(badgeEl).toBeDefined();
+ expect(badgeEl.innerHTML).toContain('pending removal');
+ });
+ });
+
+ describe('for a group not scheduled for deletion', () => {
+ beforeEach(() => {
+ group = { ...mockParentGroupItem, pendingRemoval: false };
+ vm = createComponent(group);
+ });
+
+ it('does not render the group pending removal badge', () => {
+ const groupTextContainer = vm.$el.querySelector('.group-text-container');
+
+ expect(groupTextContainer).not.toContain('pending removal');
+ });
+ });
+
+ it('should render component template correctly', () => {
+ const visibilityIconEl = vm.$el.querySelector('.item-visibility');
+
+ expect(vm.$el.getAttribute('id')).toBe('group-55');
+ expect(vm.$el.classList.contains('group-row')).toBeTruthy();
+
+ expect(vm.$el.querySelector('.group-row-contents')).toBeDefined();
+ expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined();
+ expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined();
+
+ expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined();
+ expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined();
+ expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined();
+
+ expect(vm.$el.querySelector('.avatar-container')).toBeDefined();
+ expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined();
+ expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined();
+
+ expect(vm.$el.querySelector('.title')).toBeDefined();
+ expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined();
+
+ expect(visibilityIconEl).not.toBe(null);
+ expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip);
+ expect(visibilityIconEl.querySelectorAll('svg').length).toBeGreaterThan(0);
+
+ expect(vm.$el.querySelector('.access-type')).toBeDefined();
+ expect(vm.$el.querySelector('.description')).toBeDefined();
+
+ expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js
new file mode 100644
index 00000000000..6205400eb03
--- /dev/null
+++ b/spec/frontend/groups/components/groups_spec.js
@@ -0,0 +1,72 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import groupsComponent from '~/groups/components/groups.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import eventHub from '~/groups/event_hub';
+import { mockGroups, mockPageInfo } from '../mock_data';
+
+const createComponent = (searchEmpty = false) => {
+ const Component = Vue.extend(groupsComponent);
+
+ return mountComponent(Component, {
+ groups: mockGroups,
+ pageInfo: mockPageInfo,
+ searchEmptyMessage: 'No matching results',
+ searchEmpty,
+ });
+};
+
+describe('GroupsComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+
+ return vm.$nextTick();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('methods', () => {
+ describe('change', () => {
+ it('should emit `fetchPage` event when page is changed via pagination', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation();
+
+ vm.change(2);
+
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'fetchPage',
+ 2,
+ expect.any(Object),
+ expect.any(Object),
+ expect.any(Object),
+ );
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+ expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+ expect(vm.$el.querySelector('.gl-pagination')).toBeDefined();
+ expect(vm.$el.querySelectorAll('.has-no-search-results').length).toBe(0);
+ });
+ });
+
+ it('should render empty search message when `searchEmpty` is `true`', () => {
+ vm.searchEmpty = true;
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js
new file mode 100644
index 00000000000..c0dc1a816e6
--- /dev/null
+++ b/spec/frontend/groups/components/item_actions_spec.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemActionsComponent from '~/groups/components/item_actions.vue';
+import eventHub from '~/groups/event_hub';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+ const Component = Vue.extend(itemActionsComponent);
+
+ return mountComponent(Component, {
+ group,
+ parentGroup,
+ });
+};
+
+describe('ItemActionsComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('methods', () => {
+ describe('onLeaveGroup', () => {
+ it('emits `showLeaveGroupModal` event with `group` and `parentGroup` props', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ vm.onLeaveGroup();
+
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'showLeaveGroupModal',
+ vm.group,
+ vm.parentGroup,
+ );
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.classList.contains('controls')).toBeTruthy();
+ });
+
+ it('should render Edit Group button with correct attribute values', () => {
+ const group = { ...mockParentGroupItem };
+ group.canEdit = true;
+ const newVm = createComponent(group);
+
+ const editBtn = newVm.$el.querySelector('a.edit-group');
+
+ expect(editBtn).toBeDefined();
+ expect(editBtn.classList.contains('no-expand')).toBeTruthy();
+ 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');
+
+ newVm.$destroy();
+ });
+
+ it('should render Leave Group button with correct attribute values', () => {
+ const group = { ...mockParentGroupItem };
+ group.canLeave = true;
+ const newVm = createComponent(group);
+
+ const leaveBtn = newVm.$el.querySelector('a.leave-group');
+
+ expect(leaveBtn).toBeDefined();
+ expect(leaveBtn.classList.contains('no-expand')).toBeTruthy();
+ 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');
+
+ newVm.$destroy();
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_caret_spec.js b/spec/frontend/groups/components/item_caret_spec.js
new file mode 100644
index 00000000000..bfe27be9b51
--- /dev/null
+++ b/spec/frontend/groups/components/item_caret_spec.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemCaretComponent from '~/groups/components/item_caret.vue';
+
+const createComponent = (isGroupOpen = false) => {
+ const Component = Vue.extend(itemCaretComponent);
+
+ return mountComponent(Component, {
+ isGroupOpen,
+ });
+};
+
+describe('ItemCaretComponent', () => {
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ vm = createComponent();
+ expect(vm.$el.classList.contains('folder-caret')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('svg').length).toBe(1);
+ });
+
+ 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');
+ });
+
+ 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');
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js
new file mode 100644
index 00000000000..771643609ec
--- /dev/null
+++ b/spec/frontend/groups/components/item_stats_spec.js
@@ -0,0 +1,119 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemStatsComponent from '~/groups/components/item_stats.vue';
+import {
+ mockParentGroupItem,
+ ITEM_TYPE,
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+ PROJECT_VISIBILITY_TYPE,
+} from '../mock_data';
+
+const createComponent = (item = mockParentGroupItem) => {
+ const Component = Vue.extend(itemStatsComponent);
+
+ return mountComponent(Component, {
+ item,
+ });
+};
+
+describe('ItemStatsComponent', () => {
+ describe('computed', () => {
+ describe('visibilityIcon', () => {
+ it('should return icon class based on `item.visibility` value', () => {
+ Object.keys(VISIBILITY_TYPE_ICON).forEach(visibility => {
+ const item = { ...mockParentGroupItem, visibility };
+ const vm = createComponent(item);
+
+ expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]);
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('visibilityTooltip', () => {
+ it('should return tooltip string for Group based on `item.visibility` value', () => {
+ Object.keys(GROUP_VISIBILITY_TYPE).forEach(visibility => {
+ const item = { ...mockParentGroupItem, visibility, type: ITEM_TYPE.GROUP };
+ const vm = createComponent(item);
+
+ expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]);
+ vm.$destroy();
+ });
+ });
+
+ it('should return tooltip string for Project based on `item.visibility` value', () => {
+ Object.keys(PROJECT_VISIBILITY_TYPE).forEach(visibility => {
+ const item = { ...mockParentGroupItem, visibility, type: ITEM_TYPE.PROJECT };
+ const vm = createComponent(item);
+
+ expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]);
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('isProject', () => {
+ it('should return boolean value representing whether `item.type` is Project or not', () => {
+ let item;
+ let vm;
+
+ item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT };
+ vm = createComponent(item);
+
+ expect(vm.isProject).toBeTruthy();
+ vm.$destroy();
+
+ item = { ...mockParentGroupItem, type: ITEM_TYPE.GROUP };
+ vm = createComponent(item);
+
+ expect(vm.isProject).toBeFalsy();
+ vm.$destroy();
+ });
+ });
+
+ describe('isGroup', () => {
+ it('should return boolean value representing whether `item.type` is Group or not', () => {
+ let item;
+ let vm;
+
+ item = { ...mockParentGroupItem, type: ITEM_TYPE.GROUP };
+ vm = createComponent(item);
+
+ expect(vm.isGroup).toBeTruthy();
+ vm.$destroy();
+
+ item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT };
+ vm = createComponent(item);
+
+ expect(vm.isGroup).toBeFalsy();
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element correctly', () => {
+ const vm = createComponent();
+
+ expect(vm.$el.classList.contains('stats')).toBeTruthy();
+
+ vm.$destroy();
+ });
+
+ it('renders start count and last updated information for project item correctly', () => {
+ const item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT, starCount: 4 };
+ const vm = createComponent(item);
+
+ const projectStarIconEl = vm.$el.querySelector('.project-stars');
+
+ expect(projectStarIconEl).not.toBeNull();
+ expect(projectStarIconEl.querySelectorAll('svg').length).toBeGreaterThan(0);
+ expect(projectStarIconEl.querySelectorAll('.stat-value').length).toBeGreaterThan(0);
+ expect(vm.$el.querySelectorAll('.last-updated').length).toBeGreaterThan(0);
+
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_stats_value_spec.js b/spec/frontend/groups/components/item_stats_value_spec.js
new file mode 100644
index 00000000000..da6f145fa19
--- /dev/null
+++ b/spec/frontend/groups/components/item_stats_value_spec.js
@@ -0,0 +1,82 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemStatsValueComponent from '~/groups/components/item_stats_value.vue';
+
+const createComponent = ({ title, cssClass, iconName, tooltipPlacement, value }) => {
+ const Component = Vue.extend(itemStatsValueComponent);
+
+ return mountComponent(Component, {
+ title,
+ cssClass,
+ iconName,
+ tooltipPlacement,
+ value,
+ });
+};
+
+describe('ItemStatsValueComponent', () => {
+ describe('computed', () => {
+ let vm;
+ const itemConfig = {
+ title: 'Subgroups',
+ cssClass: 'number-subgroups',
+ iconName: 'folder',
+ tooltipPlacement: 'left',
+ };
+
+ describe('isValuePresent', () => {
+ it('returns true if non-empty `value` is present', () => {
+ vm = createComponent({ ...itemConfig, value: 10 });
+
+ expect(vm.isValuePresent).toBeTruthy();
+ });
+
+ it('returns false if empty `value` is present', () => {
+ vm = createComponent(itemConfig);
+
+ expect(vm.isValuePresent).toBeFalsy();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+ beforeEach(() => {
+ vm = createComponent({
+ title: 'Subgroups',
+ cssClass: 'number-subgroups',
+ iconName: 'folder',
+ tooltipPlacement: 'left',
+ value: 10,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders component element correctly', () => {
+ expect(vm.$el.classList.contains('number-subgroups')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('svg').length).toBeGreaterThan(0);
+ expect(vm.$el.querySelectorAll('.stat-value').length).toBeGreaterThan(0);
+ });
+
+ it('renders element tooltip correctly', () => {
+ expect(vm.$el.dataset.originalTitle).toBe('Subgroups');
+ expect(vm.$el.dataset.placement).toBe('left');
+ });
+
+ it('renders element icon correctly', () => {
+ expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('folder');
+ });
+
+ it('renders value count correctly', () => {
+ expect(vm.$el.querySelector('.stat-value').innerText.trim()).toContain('10');
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js
new file mode 100644
index 00000000000..251b5b5ff4c
--- /dev/null
+++ b/spec/frontend/groups/components/item_type_icon_spec.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemTypeIconComponent from '~/groups/components/item_type_icon.vue';
+import { ITEM_TYPE } from '../mock_data';
+
+const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => {
+ const Component = Vue.extend(itemTypeIconComponent);
+
+ return mountComponent(Component, {
+ itemType,
+ isGroupOpen,
+ });
+};
+
+describe('ItemTypeIconComponent', () => {
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ const vm = createComponent();
+
+ expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy();
+ vm.$destroy();
+ });
+
+ it('should render folder open or close icon based `isGroupOpen` prop value', () => {
+ let vm;
+
+ vm = createComponent(ITEM_TYPE.GROUP, true);
+
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder-open');
+ vm.$destroy();
+
+ vm = createComponent(ITEM_TYPE.GROUP);
+
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder');
+ vm.$destroy();
+ });
+
+ it('should render bookmark icon based on `isProject` prop value', () => {
+ let vm;
+
+ vm = createComponent(ITEM_TYPE.PROJECT);
+
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('bookmark');
+ vm.$destroy();
+
+ vm = createComponent(ITEM_TYPE.GROUP);
+
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).not.toContain('bookmark');
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/mock_data.js b/spec/frontend/groups/mock_data.js
index 380dda9f7b1..380dda9f7b1 100644
--- a/spec/javascripts/groups/mock_data.js
+++ b/spec/frontend/groups/mock_data.js
diff --git a/spec/frontend/groups/service/groups_service_spec.js b/spec/frontend/groups/service/groups_service_spec.js
new file mode 100644
index 00000000000..38a565eba01
--- /dev/null
+++ b/spec/frontend/groups/service/groups_service_spec.js
@@ -0,0 +1,42 @@
+import axios from '~/lib/utils/axios_utils';
+
+import GroupsService from '~/groups/service/groups_service';
+import { mockEndpoint, mockParentGroupItem } from '../mock_data';
+
+describe('GroupsService', () => {
+ let service;
+
+ beforeEach(() => {
+ service = new GroupsService(mockEndpoint);
+ });
+
+ describe('getGroups', () => {
+ it('should return promise for `GET` request on provided endpoint', () => {
+ jest.spyOn(axios, 'get').mockResolvedValue();
+ const params = {
+ page: 2,
+ filter: 'git',
+ sort: 'created_asc',
+ archived: true,
+ };
+
+ service.getGroups(55, 2, 'git', 'created_asc', true);
+
+ expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params: { parent_id: 55 } });
+
+ service.getGroups(null, 2, 'git', 'created_asc', true);
+
+ expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params });
+ });
+ });
+
+ describe('leaveGroup', () => {
+ it('should return promise for `DELETE` request on provided endpoint', () => {
+ jest.spyOn(axios, 'delete').mockResolvedValue();
+
+ service.leaveGroup(mockParentGroupItem.leavePath);
+
+ expect(axios.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath);
+ });
+ });
+});
diff --git a/spec/frontend/groups/store/groups_store_spec.js b/spec/frontend/groups/store/groups_store_spec.js
new file mode 100644
index 00000000000..7d12f73d270
--- /dev/null
+++ b/spec/frontend/groups/store/groups_store_spec.js
@@ -0,0 +1,123 @@
+import GroupsStore from '~/groups/store/groups_store';
+import {
+ mockGroups,
+ mockSearchedGroups,
+ mockParentGroupItem,
+ mockRawChildren,
+ mockRawPageInfo,
+} from '../mock_data';
+
+describe('ProjectsStore', () => {
+ describe('constructor', () => {
+ it('should initialize default state', () => {
+ let store;
+
+ store = new GroupsStore();
+
+ expect(Object.keys(store.state).length).toBe(2);
+ expect(Array.isArray(store.state.groups)).toBeTruthy();
+ expect(Object.keys(store.state.pageInfo).length).toBe(0);
+ expect(store.hideProjects).not.toBeDefined();
+
+ store = new GroupsStore(true);
+
+ expect(store.hideProjects).toBeTruthy();
+ });
+ });
+
+ describe('setGroups', () => {
+ it('should set groups to state', () => {
+ const store = new GroupsStore();
+ jest.spyOn(store, 'formatGroupItem');
+
+ store.setGroups(mockGroups);
+
+ expect(store.state.groups.length).toBe(mockGroups.length);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object));
+ expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1);
+ });
+ });
+
+ describe('setSearchedGroups', () => {
+ it('should set searched groups to state', () => {
+ const store = new GroupsStore();
+ jest.spyOn(store, 'formatGroupItem');
+
+ store.setSearchedGroups(mockSearchedGroups);
+
+ expect(store.state.groups.length).toBe(mockSearchedGroups.length);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object));
+ expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1);
+ expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName')).toBeGreaterThan(
+ -1,
+ );
+ });
+ });
+
+ describe('setGroupChildren', () => {
+ it('should set children to group item in state', () => {
+ const store = new GroupsStore();
+ jest.spyOn(store, 'formatGroupItem');
+
+ store.setGroupChildren(mockParentGroupItem, mockRawChildren);
+
+ expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object));
+ expect(mockParentGroupItem.children.length).toBe(1);
+ expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName')).toBeGreaterThan(-1);
+ expect(mockParentGroupItem.isOpen).toBeTruthy();
+ expect(mockParentGroupItem.isChildrenLoading).toBeFalsy();
+ });
+ });
+
+ describe('setPaginationInfo', () => {
+ it('should parse and set pagination info in state', () => {
+ const store = new GroupsStore();
+
+ store.setPaginationInfo(mockRawPageInfo);
+
+ expect(store.state.pageInfo.perPage).toBe(10);
+ expect(store.state.pageInfo.page).toBe(10);
+ expect(store.state.pageInfo.total).toBe(10);
+ expect(store.state.pageInfo.totalPages).toBe(10);
+ expect(store.state.pageInfo.nextPage).toBe(10);
+ expect(store.state.pageInfo.previousPage).toBe(10);
+ });
+ });
+
+ describe('formatGroupItem', () => {
+ it('should parse group item object and return updated object', () => {
+ let store;
+ let updatedGroupItem;
+
+ store = new GroupsStore();
+ updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+
+ expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1);
+ expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count);
+ expect(updatedGroupItem.isChildrenLoading).toBe(false);
+ expect(updatedGroupItem.isBeingRemoved).toBe(false);
+
+ store = new GroupsStore(true);
+ updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+
+ expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1);
+ expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count);
+ });
+ });
+
+ describe('removeGroup', () => {
+ it('should remove children from group item in state', () => {
+ const store = new GroupsStore();
+ const rawParentGroup = { ...mockGroups[0] };
+ const rawChildGroup = { ...mockGroups[1] };
+
+ store.setGroups([rawParentGroup]);
+ store.setGroupChildren(store.state.groups[0], [rawChildGroup]);
+ const childItem = store.state.groups[0].children[0];
+
+ store.removeGroup(childItem, store.state.groups[0]);
+
+ expect(store.state.groups[0].children.length).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js
index 0a74799283a..6d2d7976196 100644
--- a/spec/frontend/header_spec.js
+++ b/spec/frontend/header_spec.js
@@ -60,8 +60,8 @@ describe('Header', () => {
beforeEach(() => {
setFixtures(`
<li class="js-nav-user-dropdown">
- <a class="js-buy-ci-minutes-link" data-track-event="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy CI minutes
- </a>
+ <a class="js-buy-ci-minutes-link" data-track-event="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy CI minutes</a>
+ <a class="js-upgrade-plan-link" data-track-event="click_upgrade_link" data-track-label="free" data-track-property="user_dropdown">Upgrade</a>
</li>`);
trackingSpy = mockTracking('_category_', $('.js-nav-user-dropdown').element, jest.spyOn);
@@ -77,8 +77,16 @@ describe('Header', () => {
it('sends a tracking event when the dropdown is opened and contains Buy CI minutes link', () => {
$('.js-nav-user-dropdown').trigger('shown.bs.dropdown');
- expect(trackingSpy).toHaveBeenCalledTimes(1);
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'show_buy_ci_minutes', {
+ expect(trackingSpy).toHaveBeenCalledWith('some:page', 'show_buy_ci_minutes', {
+ label: 'free',
+ property: 'user_dropdown',
+ });
+ });
+
+ it('sends a tracking event when the dropdown is opened and contains Upgrade link', () => {
+ $('.js-nav-user-dropdown').trigger('shown.bs.dropdown');
+
+ expect(trackingSpy).toHaveBeenCalledWith('some:page', 'show_upgrade_link', {
label: 'free',
property: 'user_dropdown',
});
diff --git a/spec/frontend/helpers/class_spec_helper.js b/spec/frontend/helpers/class_spec_helper.js
index 7a60d33b471..b26f087f0c5 100644
--- a/spec/frontend/helpers/class_spec_helper.js
+++ b/spec/frontend/helpers/class_spec_helper.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line jest/no-export
export default class ClassSpecHelper {
static itShouldBeAStaticMethod(base, method) {
return it('should be a static method', () => {
diff --git a/spec/frontend/helpers/event_hub_factory_spec.js b/spec/frontend/helpers/event_hub_factory_spec.js
new file mode 100644
index 00000000000..dcfec6b836a
--- /dev/null
+++ b/spec/frontend/helpers/event_hub_factory_spec.js
@@ -0,0 +1,94 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+describe('event bus factory', () => {
+ let eventBus;
+
+ beforeEach(() => {
+ eventBus = createEventHub();
+ });
+
+ afterEach(() => {
+ eventBus = null;
+ });
+
+ describe('underlying module', () => {
+ let mitt;
+
+ beforeEach(() => {
+ jest.resetModules();
+ jest.mock('mitt');
+
+ // eslint-disable-next-line global-require
+ mitt = require('mitt');
+ mitt.mockReturnValue(() => ({}));
+
+ const createEventHubActual = jest.requireActual('~/helpers/event_hub_factory').default;
+ eventBus = createEventHubActual();
+ });
+
+ it('creates an emitter', () => {
+ expect(mitt).toHaveBeenCalled();
+ });
+ });
+
+ describe('instance', () => {
+ it.each`
+ method
+ ${'on'}
+ ${'once'}
+ ${'off'}
+ ${'emit'}
+ `('binds $$method to $method ', ({ method }) => {
+ expect(typeof eventBus[method]).toBe('function');
+ expect(eventBus[method]).toBe(eventBus[`$${method}`]);
+ });
+ });
+
+ describe('once', () => {
+ const event = 'foobar';
+ let handler;
+
+ beforeEach(() => {
+ jest.spyOn(eventBus, 'on');
+ jest.spyOn(eventBus, 'off');
+ handler = jest.fn();
+ eventBus.once(event, handler);
+ });
+
+ it('calls on internally', () => {
+ expect(eventBus.on).toHaveBeenCalled();
+ });
+
+ it('calls handler when event is emitted', () => {
+ eventBus.emit(event);
+ expect(handler).toHaveBeenCalled();
+ });
+
+ it('calls off when event is emitted', () => {
+ eventBus.emit(event);
+ expect(eventBus.off).toHaveBeenCalled();
+ });
+
+ it('calls the handler only once when event is emitted multiple times', () => {
+ eventBus.emit(event);
+ eventBus.emit(event);
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ describe('when the handler thows an error', () => {
+ beforeEach(() => {
+ handler = jest.fn().mockImplementation(() => {
+ throw new Error();
+ });
+ eventBus.once(event, handler);
+ });
+
+ it('calls off when event is emitted', () => {
+ expect(() => {
+ eventBus.emit(event);
+ }).toThrow();
+ expect(eventBus.off).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/helpers/filtered_search_spec_helper.js b/spec/frontend/helpers/filtered_search_spec_helper.js
new file mode 100644
index 00000000000..ceb7982bbc3
--- /dev/null
+++ b/spec/frontend/helpers/filtered_search_spec_helper.js
@@ -0,0 +1,69 @@
+export default class FilteredSearchSpecHelper {
+ static createFilterVisualTokenHTML(name, operator, value, isSelected) {
+ return FilteredSearchSpecHelper.createFilterVisualToken(name, operator, value, isSelected)
+ .outerHTML;
+ }
+
+ static createFilterVisualToken(name, operator, value, isSelected = false) {
+ const li = document.createElement('li');
+ li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`);
+
+ li.innerHTML = `
+ <div class="selectable ${isSelected ? 'selected' : ''}" role="button">
+ <div class="name">${name}</div>
+ <div class="operator">${operator}</div>
+ <div class="value-container">
+ <div class="value">${value}</div>
+ <div class="remove-token" role="button">
+ <i class="fa fa-close"></i>
+ </div>
+ </div>
+ </div>
+ `;
+
+ return li;
+ }
+
+ static createNameFilterVisualTokenHTML(name) {
+ return `
+ <li class="js-visual-token filtered-search-token">
+ <div class="name">${name}</div>
+ </li>
+ `;
+ }
+
+ static createNameOperatorFilterVisualTokenHTML(name, operator) {
+ return `
+ <li class="js-visual-token filtered-search-token">
+ <div class="name">${name}</div>
+ <div class="operator">${operator}</div>
+ </li>
+ `;
+ }
+
+ static createSearchVisualToken(name) {
+ const li = document.createElement('li');
+ li.classList.add('js-visual-token', 'filtered-search-term');
+ li.innerHTML = `<div class="name">${name}</div>`;
+ return li;
+ }
+
+ static createSearchVisualTokenHTML(name) {
+ return FilteredSearchSpecHelper.createSearchVisualToken(name).outerHTML;
+ }
+
+ static createInputHTML(placeholder = '', value = '') {
+ return `
+ <li class="input-token">
+ <input type='text' class='filtered-search' placeholder='${placeholder}' value='${value}'/>
+ </li>
+ `;
+ }
+
+ static createTokensContainerHTML(html, inputPlaceholder) {
+ return `
+ ${html}
+ ${FilteredSearchSpecHelper.createInputHTML(inputPlaceholder)}
+ `;
+ }
+}
diff --git a/spec/frontend/helpers/fixtures.js b/spec/frontend/helpers/fixtures.js
index 778196843db..a89ceab3f8e 100644
--- a/spec/frontend/helpers/fixtures.js
+++ b/spec/frontend/helpers/fixtures.js
@@ -23,11 +23,12 @@ Did you run bin/rake frontend:fixtures?`,
export const getJSONFixture = relativePath => JSON.parse(getFixture(relativePath));
export const resetHTMLFixture = () => {
- document.body.textContent = '';
+ document.head.innerHTML = '';
+ document.body.innerHTML = '';
};
export const setHTMLFixture = (htmlContent, resetHook = afterEach) => {
- document.body.outerHTML = htmlContent;
+ document.body.innerHTML = htmlContent;
resetHook(resetHTMLFixture);
};
diff --git a/spec/frontend/helpers/set_window_location_helper.js b/spec/frontend/helpers/set_window_location_helper.js
new file mode 100644
index 00000000000..a94e73762c9
--- /dev/null
+++ b/spec/frontend/helpers/set_window_location_helper.js
@@ -0,0 +1,40 @@
+/**
+ * setWindowLocation allows for setting `window.location`
+ * (doing so directly is causing an error in jsdom)
+ *
+ * Example usage:
+ * assert(window.location.hash === undefined);
+ * setWindowLocation('http://example.com#foo')
+ * assert(window.location.hash === '#foo');
+ *
+ * More information:
+ * https://github.com/facebook/jest/issues/890
+ *
+ * @param url
+ */
+export default function setWindowLocation(url) {
+ const parsedUrl = new URL(url);
+
+ const newLocationValue = [
+ 'hash',
+ 'host',
+ 'hostname',
+ 'href',
+ 'origin',
+ 'pathname',
+ 'port',
+ 'protocol',
+ 'search',
+ ].reduce(
+ (location, prop) => ({
+ ...location,
+ [prop]: parsedUrl[prop],
+ }),
+ {},
+ );
+
+ Object.defineProperty(window, 'location', {
+ value: newLocationValue,
+ writable: true,
+ });
+}
diff --git a/spec/frontend/helpers/set_window_location_helper_spec.js b/spec/frontend/helpers/set_window_location_helper_spec.js
new file mode 100644
index 00000000000..2a2c024c824
--- /dev/null
+++ b/spec/frontend/helpers/set_window_location_helper_spec.js
@@ -0,0 +1,40 @@
+import setWindowLocation from './set_window_location_helper';
+
+describe('setWindowLocation', () => {
+ const originalLocation = window.location;
+
+ afterEach(() => {
+ window.location = originalLocation;
+ });
+
+ it.each`
+ url | property | value
+ ${'https://gitlab.com#foo'} | ${'hash'} | ${'#foo'}
+ ${'http://gitlab.com'} | ${'host'} | ${'gitlab.com'}
+ ${'http://gitlab.org'} | ${'hostname'} | ${'gitlab.org'}
+ ${'http://gitlab.org/foo#bar'} | ${'href'} | ${'http://gitlab.org/foo#bar'}
+ ${'http://gitlab.com'} | ${'origin'} | ${'http://gitlab.com'}
+ ${'http://gitlab.com/foo/bar/baz'} | ${'pathname'} | ${'/foo/bar/baz'}
+ ${'https://gitlab.com'} | ${'protocol'} | ${'https:'}
+ ${'http://gitlab.com#foo'} | ${'protocol'} | ${'http:'}
+ ${'http://gitlab.com:8080'} | ${'port'} | ${'8080'}
+ ${'http://gitlab.com?foo=bar&bar=foo'} | ${'search'} | ${'?foo=bar&bar=foo'}
+ `(
+ 'sets "window.location.$property" to be "$value" when called with: "$url"',
+ ({ url, property, value }) => {
+ expect(window.location).toBe(originalLocation);
+
+ setWindowLocation(url);
+
+ expect(window.location[property]).toBe(value);
+ },
+ );
+
+ it.each([null, 1, undefined, false, '', 'gitlab.com'])(
+ 'throws an error when called with an invalid url: "%s"',
+ invalidUrl => {
+ expect(() => setWindowLocation(invalidUrl)).toThrow(new TypeError('Invalid URL'));
+ expect(window.location).toBe(originalLocation);
+ },
+ );
+});
diff --git a/spec/frontend/helpers/vue_mount_component_helper.js b/spec/frontend/helpers/vue_mount_component_helper.js
index 6848c95d95d..615ff69a01c 100644
--- a/spec/frontend/helpers/vue_mount_component_helper.js
+++ b/spec/frontend/helpers/vue_mount_component_helper.js
@@ -1,22 +1,38 @@
import Vue from 'vue';
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
const mountComponent = (Component, props = {}, el = null) =>
new Component({
propsData: props,
}).$mount(el);
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
export const createComponentWithStore = (Component, store, propsData = {}) =>
new Component({
store,
propsData,
});
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
export const mountComponentWithStore = (Component, { el, props, store }) =>
new Component({
store,
propsData: props || {},
}).$mount(el);
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
export const mountComponentWithSlots = (Component, { props, slots }) => {
const component = new Component({
propsData: props || {},
@@ -30,9 +46,18 @@ export const mountComponentWithSlots = (Component, { props, slots }) => {
/**
* Mount a component with the given render method.
*
+ * -----------------------------
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ * -----------------------------
+ *
* This helps with inserting slots that need to be compiled.
*/
export const mountComponentWithRender = (render, el = null) =>
mountComponent(Vue.extend({ render }), {}, el);
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
export default mountComponent;
diff --git a/spec/frontend/helpers/web_worker_mock.js b/spec/frontend/helpers/web_worker_mock.js
new file mode 100644
index 00000000000..2b4a391e1d2
--- /dev/null
+++ b/spec/frontend/helpers/web_worker_mock.js
@@ -0,0 +1,10 @@
+/* eslint-disable class-methods-use-this */
+export default class WebWorkerMock {
+ addEventListener() {}
+
+ removeEventListener() {}
+
+ terminate() {}
+
+ postMessage() {}
+}
diff --git a/spec/frontend/ide/components/activity_bar_spec.js b/spec/frontend/ide/components/activity_bar_spec.js
new file mode 100644
index 00000000000..8b3853d4535
--- /dev/null
+++ b/spec/frontend/ide/components/activity_bar_spec.js
@@ -0,0 +1,72 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import { leftSidebarViews } from '~/ide/constants';
+import ActivityBar from '~/ide/components/activity_bar.vue';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { resetStore } from '../helpers';
+
+describe('IDE activity bar', () => {
+ const Component = Vue.extend(ActivityBar);
+ let vm;
+
+ beforeEach(() => {
+ Vue.set(store.state.projects, 'abcproject', {
+ web_url: 'testing',
+ });
+ Vue.set(store.state, 'currentProjectId', 'abcproject');
+
+ vm = createComponentWithStore(Component, store);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ describe('updateActivityBarView', () => {
+ beforeEach(() => {
+ jest.spyOn(vm, 'updateActivityBarView').mockImplementation(() => {});
+
+ vm.$mount();
+ });
+
+ it('calls updateActivityBarView with edit value on click', () => {
+ vm.$el.querySelector('.js-ide-edit-mode').click();
+
+ expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.edit.name);
+ });
+
+ it('calls updateActivityBarView with commit value on click', () => {
+ vm.$el.querySelector('.js-ide-commit-mode').click();
+
+ expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.commit.name);
+ });
+
+ it('calls updateActivityBarView with review value on click', () => {
+ vm.$el.querySelector('.js-ide-review-mode').click();
+
+ expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.review.name);
+ });
+ });
+
+ describe('active item', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ it('sets edit item active', () => {
+ expect(vm.$el.querySelector('.js-ide-edit-mode').classList).toContain('active');
+ });
+
+ it('sets commit item active', done => {
+ vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.js-ide-commit-mode').classList).toContain('active');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js
index a25aba61516..ff780939026 100644
--- a/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js
@@ -7,27 +7,32 @@ import { file } from '../../helpers';
const localVue = createLocalVue();
localVue.use(Vuex);
+const TEST_FILE_PATH = 'test/file/path';
+
describe('IDE commit editor header', () => {
let wrapper;
- let f;
let store;
- const findDiscardModal = () => wrapper.find({ ref: 'discardModal' });
- const findDiscardButton = () => wrapper.find({ ref: 'discardButton' });
-
- beforeEach(() => {
- f = file('file');
- store = createStore();
-
+ const createComponent = (fileProps = {}) => {
wrapper = mount(EditorHeader, {
store,
localVue,
propsData: {
- activeFile: f,
+ activeFile: {
+ ...file(TEST_FILE_PATH),
+ staged: true,
+ ...fileProps,
+ },
},
});
+ };
- jest.spyOn(wrapper.vm, 'discardChanges').mockImplementation();
+ const findDiscardModal = () => wrapper.find({ ref: 'discardModal' });
+ const findDiscardButton = () => wrapper.find({ ref: 'discardButton' });
+
+ beforeEach(() => {
+ store = createStore();
+ jest.spyOn(store, 'dispatch').mockImplementation();
});
afterEach(() => {
@@ -35,29 +40,38 @@ describe('IDE commit editor header', () => {
wrapper = null;
});
- it('renders button to discard', () => {
- expect(wrapper.vm.$el.querySelectorAll('.btn')).toHaveLength(1);
+ it.each`
+ fileProps | shouldExist
+ ${{ staged: false, changed: false }} | ${false}
+ ${{ staged: true, changed: false }} | ${true}
+ ${{ staged: false, changed: true }} | ${true}
+ ${{ staged: true, changed: true }} | ${true}
+ `('with $fileProps, show discard button is $shouldExist', ({ fileProps, shouldExist }) => {
+ createComponent(fileProps);
+
+ expect(findDiscardButton().exists()).toBe(shouldExist);
});
describe('discard button', () => {
- let modal;
-
beforeEach(() => {
- modal = findDiscardModal();
+ createComponent();
+ const modal = findDiscardModal();
jest.spyOn(modal.vm, 'show');
findDiscardButton().trigger('click');
});
it('opens a dialog confirming discard', () => {
- expect(modal.vm.show).toHaveBeenCalled();
+ expect(findDiscardModal().vm.show).toHaveBeenCalled();
});
it('calls discardFileChanges if dialog result is confirmed', () => {
- modal.vm.$emit('ok');
+ expect(store.dispatch).not.toHaveBeenCalled();
+
+ findDiscardModal().vm.$emit('ok');
- expect(wrapper.vm.discardChanges).toHaveBeenCalledWith(f.path);
+ expect(store.dispatch).toHaveBeenCalledWith('discardFileChanges', TEST_FILE_PATH);
});
});
});
diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js
index dfde69ab2df..129180bb46e 100644
--- a/spec/frontend/ide/components/commit_sidebar/form_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js
@@ -1,6 +1,5 @@
import Vue from 'vue';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
-import waitForPromises from 'helpers/wait_for_promises';
import { projectData } from 'jest/ide/mock_data';
import store from '~/ide/stores';
import CommitForm from '~/ide/components/commit_sidebar/form.vue';
@@ -31,10 +30,10 @@ describe('IDE commit form', () => {
});
describe('compact', () => {
- beforeEach(done => {
+ beforeEach(() => {
vm.isCompact = true;
- vm.$nextTick(done);
+ return vm.$nextTick();
});
it('renders commit button in compact mode', () => {
@@ -46,95 +45,84 @@ describe('IDE commit form', () => {
expect(vm.$el.querySelector('form')).toBeNull();
});
- it('renders overview text', done => {
+ it('renders overview text', () => {
vm.$store.state.stagedFiles.push('test');
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(vm.$el.querySelector('p').textContent).toContain('1 changed file');
- done();
});
});
- it('shows form when clicking commit button', done => {
+ it('shows form when clicking commit button', () => {
vm.$el.querySelector('.btn-primary').click();
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(vm.$el.querySelector('form')).not.toBeNull();
-
- done();
});
});
- it('toggles activity bar view when clicking commit button', done => {
+ it('toggles activity bar view when clicking commit button', () => {
vm.$el.querySelector('.btn-primary').click();
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(store.state.currentActivityView).toBe(leftSidebarViews.commit.name);
-
- done();
});
});
- it('collapses if lastCommitMsg is set to empty and current view is not commit view', done => {
+ it('collapses if lastCommitMsg is set to empty and current view is not commit view', () => {
store.state.lastCommitMsg = 'abc';
store.state.currentActivityView = leftSidebarViews.edit.name;
- vm.$nextTick(() => {
- // if commit message is set, form is uncollapsed
- expect(vm.isCompact).toBe(false);
+ return vm
+ .$nextTick()
+ .then(() => {
+ // if commit message is set, form is uncollapsed
+ expect(vm.isCompact).toBe(false);
- store.state.lastCommitMsg = '';
+ store.state.lastCommitMsg = '';
- vm.$nextTick(() => {
+ return vm.$nextTick();
+ })
+ .then(() => {
// collapsed when set to empty
expect(vm.isCompact).toBe(true);
-
- done();
});
- });
});
});
describe('full', () => {
- beforeEach(done => {
+ beforeEach(() => {
vm.isCompact = false;
- vm.$nextTick(done);
+ return vm.$nextTick();
});
- it('updates commitMessage in store on input', done => {
+ it('updates commitMessage in store on input', () => {
const textarea = vm.$el.querySelector('textarea');
textarea.value = 'testing commit message';
textarea.dispatchEvent(new Event('input'));
- waitForPromises()
- .then(() => {
- expect(vm.$store.state.commit.commitMessage).toBe('testing commit message');
- })
- .then(done)
- .catch(done.fail);
+ return vm.$nextTick().then(() => {
+ expect(vm.$store.state.commit.commitMessage).toBe('testing commit message');
+ });
});
- it('updating currentActivityView not to commit view sets compact mode', done => {
+ it('updating currentActivityView not to commit view sets compact mode', () => {
store.state.currentActivityView = 'a';
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(vm.isCompact).toBe(true);
-
- done();
});
});
- it('always opens itself in full view current activity view is not commit view when clicking commit button', done => {
+ it('always opens itself in full view current activity view is not commit view when clicking commit button', () => {
vm.$el.querySelector('.btn-primary').click();
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(store.state.currentActivityView).toBe(leftSidebarViews.commit.name);
expect(vm.isCompact).toBe(false);
-
- done();
});
});
@@ -143,41 +131,54 @@ describe('IDE commit form', () => {
expect(vm.$el.querySelector('.btn-default').textContent).toContain('Collapse');
});
- it('resets commitMessage when clicking discard button', done => {
+ it('resets commitMessage when clicking discard button', () => {
vm.$store.state.commit.commitMessage = 'testing commit message';
- waitForPromises()
+ return vm
+ .$nextTick()
.then(() => {
vm.$el.querySelector('.btn-default').click();
})
- .then(Vue.nextTick)
+ .then(() => vm.$nextTick())
.then(() => {
expect(vm.$store.state.commit.commitMessage).not.toBe('testing commit message');
- })
- .then(done)
- .catch(done.fail);
+ });
});
});
describe('when submitting', () => {
beforeEach(() => {
- jest.spyOn(vm, 'commitChanges').mockImplementation(() => {});
+ jest.spyOn(vm, 'commitChanges');
+
vm.$store.state.stagedFiles.push('test');
+ vm.$store.state.commit.commitMessage = 'testing commit message';
});
- it('calls commitChanges', done => {
- vm.$store.state.commit.commitMessage = 'testing commit message';
+ it('calls commitChanges', () => {
+ vm.commitChanges.mockResolvedValue({ success: true });
+
+ return vm.$nextTick().then(() => {
+ vm.$el.querySelector('.btn-success').click();
+
+ expect(vm.commitChanges).toHaveBeenCalled();
+ });
+ });
+
+ it('opens new branch modal if commitChanges throws an error', () => {
+ vm.commitChanges.mockRejectedValue({ success: false });
- waitForPromises()
+ jest.spyOn(vm.$refs.createBranchModal, 'show').mockImplementation();
+
+ return vm
+ .$nextTick()
.then(() => {
vm.$el.querySelector('.btn-success').click();
+
+ return vm.$nextTick();
})
- .then(Vue.nextTick)
.then(() => {
- expect(vm.commitChanges).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ expect(vm.$refs.createBranchModal.show).toHaveBeenCalled();
+ });
});
});
});
diff --git a/spec/frontend/ide/components/commit_sidebar/list_spec.js b/spec/frontend/ide/components/commit_sidebar/list_spec.js
index ee209487665..2b5664ffc4e 100644
--- a/spec/frontend/ide/components/commit_sidebar/list_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/list_spec.js
@@ -21,8 +21,6 @@ describe('Multi-file editor commit sidebar list', () => {
keyPrefix: 'staged',
});
- vm.$store.state.rightPanelCollapsed = false;
-
vm.$mount();
});
diff --git a/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js
new file mode 100644
index 00000000000..ac80ba58056
--- /dev/null
+++ b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js
@@ -0,0 +1,134 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { resetStore } from 'jest/ide/helpers';
+import store from '~/ide/stores';
+import radioGroup from '~/ide/components/commit_sidebar/radio_group.vue';
+
+describe('IDE commit sidebar radio group', () => {
+ let vm;
+
+ beforeEach(done => {
+ const Component = Vue.extend(radioGroup);
+
+ store.state.commit.commitAction = '2';
+
+ vm = createComponentWithStore(Component, store, {
+ value: '1',
+ label: 'test',
+ checked: true,
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('uses label if present', () => {
+ expect(vm.$el.textContent).toContain('test');
+ });
+
+ it('uses slot if label is not present', done => {
+ vm.$destroy();
+
+ vm = new Vue({
+ components: {
+ radioGroup,
+ },
+ store,
+ render: createElement =>
+ createElement('radio-group', { props: { value: '1' } }, 'Testing slot'),
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(() => {
+ expect(vm.$el.textContent).toContain('Testing slot');
+
+ done();
+ });
+ });
+
+ it('updates store when changing radio button', done => {
+ vm.$el.querySelector('input').dispatchEvent(new Event('change'));
+
+ Vue.nextTick(() => {
+ expect(store.state.commit.commitAction).toBe('1');
+
+ done();
+ });
+ });
+
+ describe('with input', () => {
+ beforeEach(done => {
+ vm.$destroy();
+
+ const Component = Vue.extend(radioGroup);
+
+ store.state.commit.commitAction = '1';
+ store.state.commit.newBranchName = 'test-123';
+
+ vm = createComponentWithStore(Component, store, {
+ value: '1',
+ label: 'test',
+ checked: true,
+ showInput: true,
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(done);
+ });
+
+ it('renders input box when commitAction matches value', () => {
+ expect(vm.$el.querySelector('.form-control')).not.toBeNull();
+ });
+
+ it('hides input when commitAction doesnt match value', done => {
+ store.state.commit.commitAction = '2';
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.form-control')).toBeNull();
+ done();
+ });
+ });
+
+ it('updates branch name in store on input', done => {
+ const input = vm.$el.querySelector('.form-control');
+ input.value = 'testing-123';
+ input.dispatchEvent(new Event('input'));
+
+ Vue.nextTick(() => {
+ expect(store.state.commit.newBranchName).toBe('testing-123');
+
+ done();
+ });
+ });
+
+ it('renders newBranchName if present', () => {
+ const input = vm.$el.querySelector('.form-control');
+
+ expect(input.value).toBe('test-123');
+ });
+ });
+
+ describe('tooltipTitle', () => {
+ it('returns title when disabled', () => {
+ vm.title = 'test title';
+ vm.disabled = true;
+
+ expect(vm.tooltipTitle).toBe('test title');
+ });
+
+ it('returns blank when not disabled', () => {
+ vm.title = 'test title';
+
+ expect(vm.tooltipTitle).not.toBe('test title');
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/file_row_extra_spec.js b/spec/frontend/ide/components/file_row_extra_spec.js
new file mode 100644
index 00000000000..e78bacadebb
--- /dev/null
+++ b/spec/frontend/ide/components/file_row_extra_spec.js
@@ -0,0 +1,170 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { createStore } from '~/ide/stores';
+import FileRowExtra from '~/ide/components/file_row_extra.vue';
+import { file, resetStore } from '../helpers';
+
+describe('IDE extra file row component', () => {
+ let Component;
+ let vm;
+ let unstagedFilesCount = 0;
+ let stagedFilesCount = 0;
+ let changesCount = 0;
+
+ beforeAll(() => {
+ Component = Vue.extend(FileRowExtra);
+ });
+
+ beforeEach(() => {
+ vm = createComponentWithStore(Component, createStore(), {
+ file: {
+ ...file('test'),
+ },
+ dropdownOpen: false,
+ });
+
+ jest.spyOn(vm, 'getUnstagedFilesCountForPath', 'get').mockReturnValue(() => unstagedFilesCount);
+ jest.spyOn(vm, 'getStagedFilesCountForPath', 'get').mockReturnValue(() => stagedFilesCount);
+ jest.spyOn(vm, 'getChangesInFolder', 'get').mockReturnValue(() => changesCount);
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ resetStore(vm.$store);
+
+ stagedFilesCount = 0;
+ unstagedFilesCount = 0;
+ changesCount = 0;
+ });
+
+ describe('folderChangesTooltip', () => {
+ it('returns undefined when changes count is 0', () => {
+ changesCount = 0;
+
+ expect(vm.folderChangesTooltip).toBe(undefined);
+ });
+
+ [{ input: 1, output: '1 changed file' }, { input: 2, output: '2 changed files' }].forEach(
+ ({ input, output }) => {
+ it('returns changed files count if changes count is not 0', () => {
+ changesCount = input;
+
+ expect(vm.folderChangesTooltip).toBe(output);
+ });
+ },
+ );
+ });
+
+ describe('show tree changes count', () => {
+ it('does not show for blobs', () => {
+ vm.file.type = 'blob';
+
+ expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
+ });
+
+ it('does not show when changes count is 0', () => {
+ vm.file.type = 'tree';
+
+ expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
+ });
+
+ it('does not show when tree is open', done => {
+ vm.file.type = 'tree';
+ vm.file.opened = true;
+ changesCount = 1;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
+
+ done();
+ });
+ });
+
+ it('shows for trees with changes', done => {
+ vm.file.type = 'tree';
+ vm.file.opened = false;
+ changesCount = 1;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-tree-changes')).not.toBe(null);
+
+ done();
+ });
+ });
+ });
+
+ describe('changes file icon', () => {
+ it('hides when file is not changed', () => {
+ expect(vm.$el.querySelector('.file-changed-icon')).toBe(null);
+ });
+
+ it('shows when file is changed', done => {
+ vm.file.changed = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('shows when file is staged', done => {
+ vm.file.staged = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('shows when file is a tempFile', done => {
+ vm.file.tempFile = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('shows when file is renamed', done => {
+ vm.file.prevPath = 'original-file';
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('hides when file is renamed', done => {
+ vm.file.prevPath = 'original-file';
+ vm.file.type = 'tree';
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).toBe(null);
+
+ done();
+ });
+ });
+ });
+
+ describe('merge request icon', () => {
+ it('hides when not a merge request change', () => {
+ expect(vm.$el.querySelector('.ic-git-merge')).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);
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/file_templates/bar_spec.js b/spec/frontend/ide/components/file_templates/bar_spec.js
new file mode 100644
index 00000000000..21dbe18a223
--- /dev/null
+++ b/spec/frontend/ide/components/file_templates/bar_spec.js
@@ -0,0 +1,117 @@
+import Vue from 'vue';
+import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { createStore } from '~/ide/stores';
+import Bar from '~/ide/components/file_templates/bar.vue';
+import { resetStore, file } from '../../helpers';
+
+describe('IDE file templates bar component', () => {
+ let Component;
+ let vm;
+
+ beforeAll(() => {
+ Component = Vue.extend(Bar);
+ });
+
+ beforeEach(() => {
+ const store = createStore();
+
+ store.state.openFiles.push({
+ ...file('file'),
+ opened: true,
+ active: true,
+ });
+
+ vm = mountComponentWithStore(Component, { store });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ resetStore(vm.$store);
+ });
+
+ describe('template type dropdown', () => {
+ it('renders dropdown component', () => {
+ expect(vm.$el.querySelector('.dropdown').textContent).toContain('Choose a type');
+ });
+
+ it('calls setSelectedTemplateType when clicking item', () => {
+ jest.spyOn(vm, 'setSelectedTemplateType').mockImplementation();
+
+ vm.$el.querySelector('.dropdown-content button').click();
+
+ expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({
+ name: '.gitlab-ci.yml',
+ key: 'gitlab_ci_ymls',
+ });
+ });
+ });
+
+ describe('template dropdown', () => {
+ beforeEach(done => {
+ vm.$store.state.fileTemplates.templates = [
+ {
+ name: 'test',
+ },
+ ];
+ vm.$store.state.fileTemplates.selectedTemplateType = {
+ name: '.gitlab-ci.yml',
+ key: 'gitlab_ci_ymls',
+ };
+
+ vm.$nextTick(done);
+ });
+
+ it('renders dropdown component', () => {
+ expect(vm.$el.querySelectorAll('.dropdown')[1].textContent).toContain('Choose a template');
+ });
+
+ it('calls fetchTemplate on click', () => {
+ jest.spyOn(vm, 'fetchTemplate').mockImplementation();
+
+ vm.$el
+ .querySelectorAll('.dropdown-content')[1]
+ .querySelector('button')
+ .click();
+
+ expect(vm.fetchTemplate).toHaveBeenCalledWith({
+ name: 'test',
+ });
+ });
+ });
+
+ it('shows undo button if updateSuccess is true', done => {
+ vm.$store.state.fileTemplates.updateSuccess = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.btn-default').style.display).not.toBe('none');
+
+ done();
+ });
+ });
+
+ it('calls undoFileTemplate when clicking undo button', () => {
+ jest.spyOn(vm, 'undoFileTemplate').mockImplementation();
+
+ vm.$el.querySelector('.btn-default').click();
+
+ expect(vm.undoFileTemplate).toHaveBeenCalled();
+ });
+
+ it('calls setSelectedTemplateType if activeFile name matches a template', done => {
+ const fileName = '.gitlab-ci.yml';
+
+ jest.spyOn(vm, 'setSelectedTemplateType').mockImplementation(() => {});
+ vm.$store.state.openFiles[0].name = fileName;
+
+ vm.setInitialType();
+
+ vm.$nextTick(() => {
+ expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({
+ name: fileName,
+ key: 'gitlab_ci_ymls',
+ });
+
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_review_spec.js b/spec/frontend/ide/components/ide_review_spec.js
new file mode 100644
index 00000000000..b56957e1f6d
--- /dev/null
+++ b/spec/frontend/ide/components/ide_review_spec.js
@@ -0,0 +1,73 @@
+import Vue from 'vue';
+import IdeReview from '~/ide/components/ide_review.vue';
+import { createStore } from '~/ide/stores';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { trimText } from '../../helpers/text_helper';
+import { resetStore, file } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IDE review mode', () => {
+ const Component = Vue.extend(IdeReview);
+ let vm;
+ let store;
+
+ beforeEach(() => {
+ store = createStore();
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = { ...projectData };
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree: [file('fileName')],
+ loading: false,
+ });
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders list of files', () => {
+ expect(vm.$el.textContent).toContain('fileName');
+ });
+
+ describe('merge request', () => {
+ beforeEach(() => {
+ store.state.currentMergeRequestId = '1';
+ store.state.projects.abcproject.mergeRequests['1'] = {
+ iid: 123,
+ web_url: 'testing123',
+ };
+
+ return vm.$nextTick();
+ });
+
+ it('renders edit dropdown', () => {
+ expect(vm.$el.querySelector('.btn')).not.toBe(null);
+ });
+
+ it('renders merge request link & IID', () => {
+ store.state.viewer = 'mrdiff';
+
+ return vm.$nextTick(() => {
+ const link = vm.$el.querySelector('.ide-review-sub-header');
+
+ expect(link.querySelector('a').getAttribute('href')).toBe('testing123');
+ expect(trimText(link.textContent)).toBe('Merge request (!123)');
+ });
+ });
+
+ it('changes text to latest changes when viewer is not mrdiff', () => {
+ store.state.viewer = 'diff';
+
+ return vm.$nextTick(() => {
+ expect(trimText(vm.$el.querySelector('.ide-review-sub-header').textContent)).toBe(
+ 'Latest changes',
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js
new file mode 100644
index 00000000000..65cad2e7eb0
--- /dev/null
+++ b/spec/frontend/ide/components/ide_side_bar_spec.js
@@ -0,0 +1,57 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import store from '~/ide/stores';
+import ideSidebar from '~/ide/components/ide_side_bar.vue';
+import { leftSidebarViews } from '~/ide/constants';
+import { resetStore } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IdeSidebar', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(ideSidebar);
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.projects.abcproject = projectData;
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders a sidebar', () => {
+ expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull();
+ });
+
+ it('renders loading icon component', done => {
+ vm.$store.state.loading = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
+ expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
+
+ done();
+ });
+ });
+
+ describe('activityBarComponent', () => {
+ it('renders tree component', () => {
+ expect(vm.$el.querySelector('.ide-file-list')).not.toBeNull();
+ });
+
+ it('renders commit component', done => {
+ vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.multi-file-commit-panel-section')).not.toBeNull();
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js
new file mode 100644
index 00000000000..78a280e6304
--- /dev/null
+++ b/spec/frontend/ide/components/ide_spec.js
@@ -0,0 +1,125 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import store from '~/ide/stores';
+import ide from '~/ide/components/ide.vue';
+import { file, resetStore } from '../helpers';
+import { projectData } from '../mock_data';
+
+function bootstrap(projData) {
+ const Component = Vue.extend(ide);
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = { ...projData };
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree: [],
+ loading: false,
+ });
+
+ return createComponentWithStore(Component, store, {
+ emptyStateSvgPath: 'svg',
+ noChangesStateSvgPath: 'svg',
+ committedStateSvgPath: 'svg',
+ });
+}
+
+describe('ide component, empty repo', () => {
+ let vm;
+
+ beforeEach(() => {
+ const emptyProjData = { ...projectData, empty_repo: true, branches: {} };
+ vm = bootstrap(emptyProjData);
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders "New file" button in empty repo', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).not.toBeNull();
+ done();
+ });
+ });
+});
+
+describe('ide component, non-empty repo', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = bootstrap(projectData);
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('shows error message when set', done => {
+ expect(vm.$el.querySelector('.gl-alert')).toBe(null);
+
+ vm.$store.state.errorMessage = {
+ text: 'error',
+ };
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.gl-alert')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ describe('onBeforeUnload', () => {
+ it('returns undefined when no staged files or changed files', () => {
+ expect(vm.onBeforeUnload()).toBe(undefined);
+ });
+
+ it('returns warning text when their are changed files', () => {
+ vm.$store.state.changedFiles.push(file());
+
+ expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?');
+ });
+
+ it('returns warning text when their are staged files', () => {
+ vm.$store.state.stagedFiles.push(file());
+
+ expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?');
+ });
+
+ it('updates event object', () => {
+ const event = {};
+ vm.$store.state.stagedFiles.push(file());
+
+ vm.onBeforeUnload(event);
+
+ expect(event.returnValue).toBe('Are you sure you want to lose unsaved changes?');
+ });
+ });
+
+ describe('non-existent branch', () => {
+ it('does not render "New file" button for non-existent branch when repo is not empty', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull();
+ done();
+ });
+ });
+ });
+
+ describe('branch with files', () => {
+ beforeEach(() => {
+ store.state.trees['abcproject/master'].tree = [file()];
+ });
+
+ it('does not render "New file" button', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_status_bar_spec.js b/spec/frontend/ide/components/ide_status_bar_spec.js
new file mode 100644
index 00000000000..bc8144f544c
--- /dev/null
+++ b/spec/frontend/ide/components/ide_status_bar_spec.js
@@ -0,0 +1,127 @@
+import Vue from 'vue';
+import _ from 'lodash';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { TEST_HOST } from '../../helpers/test_constants';
+import { createStore } from '~/ide/stores';
+import IdeStatusBar from '~/ide/components/ide_status_bar.vue';
+import { rightSidebarViews } from '~/ide/constants';
+import { projectData } from '../mock_data';
+
+const TEST_PROJECT_ID = 'abcproject';
+const TEST_MERGE_REQUEST_ID = '9001';
+const TEST_MERGE_REQUEST_URL = `${TEST_HOST}merge-requests/${TEST_MERGE_REQUEST_ID}`;
+
+describe('ideStatusBar', () => {
+ let store;
+ let vm;
+
+ const createComponent = () => {
+ vm = createComponentWithStore(Vue.extend(IdeStatusBar), store).$mount();
+ };
+ const findMRStatus = () => vm.$el.querySelector('.js-ide-status-mr');
+
+ beforeEach(() => {
+ store = createStore();
+ store.state.currentProjectId = TEST_PROJECT_ID;
+ store.state.projects[TEST_PROJECT_ID] = _.clone(projectData);
+ store.state.currentBranchId = 'master';
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('triggers a setInterval', () => {
+ expect(vm.intervalId).not.toBe(null);
+ });
+
+ it('renders the statusbar', () => {
+ expect(vm.$el.className).toBe('ide-status-bar');
+ });
+
+ describe('commitAgeUpdate', () => {
+ beforeEach(() => {
+ jest.spyOn(vm, 'commitAgeUpdate').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ });
+
+ it('gets called every second', () => {
+ expect(vm.commitAgeUpdate).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(1000);
+
+ expect(vm.commitAgeUpdate.mock.calls.length).toEqual(1);
+
+ jest.advanceTimersByTime(1000);
+
+ expect(vm.commitAgeUpdate.mock.calls.length).toEqual(2);
+ });
+ });
+
+ describe('getCommitPath', () => {
+ it('returns the path to the commit details', () => {
+ expect(vm.getCommitPath('abc123de')).toBe('/commit/abc123de');
+ });
+ });
+
+ describe('pipeline status', () => {
+ it('opens right sidebar on clicking icon', done => {
+ jest.spyOn(vm, 'openRightPane').mockImplementation(() => {});
+ Vue.set(vm.$store.state.pipelines, 'latestPipeline', {
+ details: {
+ status: {
+ text: 'success',
+ details_path: 'test',
+ icon: 'status_success',
+ },
+ },
+ commit: {
+ author_gravatar_url: 'www',
+ },
+ });
+
+ vm.$nextTick()
+ .then(() => {
+ vm.$el.querySelector('.ide-status-pipeline button').click();
+
+ expect(vm.openRightPane).toHaveBeenCalledWith(rightSidebarViews.pipelines);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ it('does not show merge request status', () => {
+ expect(findMRStatus()).toBe(null);
+ });
+ });
+
+ describe('with merge request in store', () => {
+ beforeEach(() => {
+ store.state.projects[TEST_PROJECT_ID].mergeRequests = {
+ [TEST_MERGE_REQUEST_ID]: {
+ web_url: TEST_MERGE_REQUEST_URL,
+ references: {
+ short: `!${TEST_MERGE_REQUEST_ID}`,
+ },
+ },
+ };
+ store.state.currentMergeRequestId = TEST_MERGE_REQUEST_ID;
+
+ createComponent();
+ });
+
+ it('shows merge request status', () => {
+ expect(findMRStatus().textContent.trim()).toEqual(`Merge request !${TEST_MERGE_REQUEST_ID}`);
+ expect(findMRStatus().querySelector('a').href).toEqual(TEST_MERGE_REQUEST_URL);
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js
new file mode 100644
index 00000000000..30f11db3153
--- /dev/null
+++ b/spec/frontend/ide/components/ide_tree_list_spec.js
@@ -0,0 +1,77 @@
+import Vue from 'vue';
+import IdeTreeList from '~/ide/components/ide_tree_list.vue';
+import store from '~/ide/stores';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { resetStore, file } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IDE tree list', () => {
+ const Component = Vue.extend(IdeTreeList);
+ const normalBranchTree = [file('fileName')];
+ const emptyBranchTree = [];
+ let vm;
+
+ const bootstrapWithTree = (tree = normalBranchTree) => {
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = { ...projectData };
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree,
+ loading: false,
+ });
+
+ vm = createComponentWithStore(Component, store, {
+ viewerType: 'edit',
+ });
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ describe('normal branch', () => {
+ beforeEach(() => {
+ bootstrapWithTree();
+
+ jest.spyOn(vm, 'updateViewer');
+
+ vm.$mount();
+ });
+
+ it('updates viewer on mount', () => {
+ expect(vm.updateViewer).toHaveBeenCalledWith('edit');
+ });
+
+ it('renders loading indicator', done => {
+ store.state.trees['abcproject/master'].loading = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
+ expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
+
+ done();
+ });
+ });
+
+ it('renders list of files', () => {
+ expect(vm.$el.textContent).toContain('fileName');
+ });
+ });
+
+ describe('empty-branch state', () => {
+ beforeEach(() => {
+ bootstrapWithTree(emptyBranchTree);
+
+ jest.spyOn(vm, 'updateViewer');
+
+ vm.$mount();
+ });
+
+ it('does not load files if the branch is empty', () => {
+ expect(vm.$el.textContent).not.toContain('fileName');
+ expect(vm.$el.textContent).toContain('No files');
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_tree_spec.js b/spec/frontend/ide/components/ide_tree_spec.js
new file mode 100644
index 00000000000..01f007f09c3
--- /dev/null
+++ b/spec/frontend/ide/components/ide_tree_spec.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+import IdeTree from '~/ide/components/ide_tree.vue';
+import store from '~/ide/stores';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { resetStore, file } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IdeRepoTree', () => {
+ let vm;
+
+ beforeEach(() => {
+ const IdeRepoTree = Vue.extend(IdeTree);
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = { ...projectData };
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree: [file('fileName')],
+ loading: false,
+ });
+
+ vm = createComponentWithStore(IdeRepoTree, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders list of files', () => {
+ expect(vm.$el.textContent).toContain('fileName');
+ });
+});
diff --git a/spec/javascripts/ide/components/jobs/detail/description_spec.js b/spec/frontend/ide/components/jobs/detail/description_spec.js
index babae00d2f7..babae00d2f7 100644
--- a/spec/javascripts/ide/components/jobs/detail/description_spec.js
+++ b/spec/frontend/ide/components/jobs/detail/description_spec.js
diff --git a/spec/javascripts/ide/components/jobs/item_spec.js b/spec/frontend/ide/components/jobs/item_spec.js
index 2f97d39e98e..2f97d39e98e 100644
--- a/spec/javascripts/ide/components/jobs/item_spec.js
+++ b/spec/frontend/ide/components/jobs/item_spec.js
diff --git a/spec/frontend/ide/components/merge_requests/item_spec.js b/spec/frontend/ide/components/merge_requests/item_spec.js
new file mode 100644
index 00000000000..6a2451ad263
--- /dev/null
+++ b/spec/frontend/ide/components/merge_requests/item_spec.js
@@ -0,0 +1,63 @@
+import Vue from 'vue';
+import router from '~/ide/ide_router';
+import Item from '~/ide/components/merge_requests/item.vue';
+import mountCompontent from '../../../helpers/vue_mount_component_helper';
+
+describe('IDE merge request item', () => {
+ const Component = Vue.extend(Item);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountCompontent(Component, {
+ item: {
+ iid: 1,
+ projectPathWithNamespace: 'gitlab-org/gitlab-ce',
+ title: 'Merge request title',
+ },
+ currentId: '1',
+ currentProjectId: 'gitlab-org/gitlab-ce',
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders merge requests data', () => {
+ expect(vm.$el.textContent).toContain('Merge request title');
+ expect(vm.$el.textContent).toContain('gitlab-org/gitlab-ce!1');
+ });
+
+ it('renders link with href', () => {
+ const expectedHref = router.resolve(
+ `/project/${vm.item.projectPathWithNamespace}/merge_requests/${vm.item.iid}`,
+ ).href;
+
+ expect(vm.$el.tagName.toLowerCase()).toBe('a');
+ expect(vm.$el).toHaveAttr('href', expectedHref);
+ });
+
+ it('renders icon if ID matches currentId', () => {
+ expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null);
+ });
+
+ it('does not render icon if ID does not match currentId', done => {
+ vm.currentId = '2';
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null);
+
+ done();
+ });
+ });
+
+ it('does not render icon if project ID does not match', done => {
+ vm.currentProjectId = 'test/test';
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null);
+
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/nav_dropdown_button_spec.js b/spec/frontend/ide/components/nav_dropdown_button_spec.js
new file mode 100644
index 00000000000..2aa3992a6d8
--- /dev/null
+++ b/spec/frontend/ide/components/nav_dropdown_button_spec.js
@@ -0,0 +1,93 @@
+import Vue from 'vue';
+import { trimText } from 'helpers/text_helper';
+import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import NavDropdownButton from '~/ide/components/nav_dropdown_button.vue';
+import { createStore } from '~/ide/stores';
+
+describe('NavDropdown', () => {
+ const TEST_BRANCH_ID = 'lorem-ipsum-dolar';
+ const TEST_MR_ID = '12345';
+ let store;
+ let vm;
+
+ beforeEach(() => {
+ store = createStore();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ const createComponent = (props = {}) => {
+ vm = mountComponentWithStore(Vue.extend(NavDropdownButton), { props, store });
+ vm.$mount();
+ };
+
+ const findIcon = name => vm.$el.querySelector(`.ic-${name}`);
+ const findMRIcon = () => findIcon('merge-request');
+ const findBranchIcon = () => findIcon('branch');
+
+ describe('normal', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders empty placeholders, if state is falsey', () => {
+ expect(trimText(vm.$el.textContent)).toEqual('- -');
+ });
+
+ it('renders branch name, if state has currentBranchId', done => {
+ vm.$store.state.currentBranchId = TEST_BRANCH_ID;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} -`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders mr id, if state has currentMergeRequestId', done => {
+ vm.$store.state.currentMergeRequestId = TEST_MR_ID;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(trimText(vm.$el.textContent)).toEqual(`- !${TEST_MR_ID}`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders branch and mr, if state has both', done => {
+ vm.$store.state.currentBranchId = TEST_BRANCH_ID;
+ vm.$store.state.currentMergeRequestId = TEST_MR_ID;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} !${TEST_MR_ID}`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('shows icons', () => {
+ expect(findBranchIcon()).toBeTruthy();
+ expect(findMRIcon()).toBeTruthy();
+ });
+ });
+
+ describe('with showMergeRequests false', () => {
+ beforeEach(() => {
+ createComponent({ showMergeRequests: false });
+ });
+
+ it('shows single empty placeholder, if state is falsey', () => {
+ expect(trimText(vm.$el.textContent)).toEqual('-');
+ });
+
+ it('shows only branch icon', () => {
+ expect(findBranchIcon()).toBeTruthy();
+ expect(findMRIcon()).toBe(null);
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/nav_dropdown_spec.js b/spec/frontend/ide/components/nav_dropdown_spec.js
new file mode 100644
index 00000000000..ce123d925c8
--- /dev/null
+++ b/spec/frontend/ide/components/nav_dropdown_spec.js
@@ -0,0 +1,102 @@
+import $ from 'jquery';
+import { mount } from '@vue/test-utils';
+import { createStore } from '~/ide/stores';
+import NavDropdown from '~/ide/components/nav_dropdown.vue';
+import { PERMISSION_READ_MR } from '~/ide/constants';
+
+const TEST_PROJECT_ID = 'lorem-ipsum';
+
+describe('IDE NavDropdown', () => {
+ let store;
+ let wrapper;
+
+ beforeEach(() => {
+ store = createStore();
+ Object.assign(store.state, {
+ currentProjectId: TEST_PROJECT_ID,
+ currentBranchId: 'master',
+ projects: {
+ [TEST_PROJECT_ID]: {
+ userPermissions: {
+ [PERMISSION_READ_MR]: true,
+ },
+ branches: {
+ master: { id: 'master' },
+ },
+ },
+ },
+ });
+ jest.spyOn(store, 'dispatch').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const createComponent = () => {
+ wrapper = mount(NavDropdown, {
+ store,
+ });
+ };
+
+ const findIcon = name => wrapper.find(`.ic-${name}`);
+ const findMRIcon = () => findIcon('merge-request');
+ const findNavForm = () => wrapper.find('.ide-nav-form');
+ const showDropdown = () => {
+ $(wrapper.vm.$el).trigger('show.bs.dropdown');
+ };
+ const hideDropdown = () => {
+ $(wrapper.vm.$el).trigger('hide.bs.dropdown');
+ };
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders nothing initially', () => {
+ expect(findNavForm().exists()).toBe(false);
+ });
+
+ it('renders nav form when show.bs.dropdown', done => {
+ showDropdown();
+
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(findNavForm().exists()).toBe(true);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('destroys nav form when closed', done => {
+ showDropdown();
+ hideDropdown();
+
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(findNavForm().exists()).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders merge request icon', () => {
+ expect(findMRIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('when user cannot read merge requests', () => {
+ beforeEach(() => {
+ store.state.projects[TEST_PROJECT_ID].userPermissions = {};
+
+ createComponent();
+ });
+
+ it('does not render merge requests', () => {
+ expect(findMRIcon().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/new_dropdown/button_spec.js b/spec/frontend/ide/components/new_dropdown/button_spec.js
new file mode 100644
index 00000000000..3c611b7de8f
--- /dev/null
+++ b/spec/frontend/ide/components/new_dropdown/button_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import Button from '~/ide/components/new_dropdown/button.vue';
+
+describe('IDE new entry dropdown button component', () => {
+ let Component;
+ let vm;
+
+ beforeAll(() => {
+ Component = Vue.extend(Button);
+ });
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ label: 'Testing',
+ icon: 'doc-new',
+ });
+
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders button with label', () => {
+ expect(vm.$el.textContent).toContain('Testing');
+ });
+
+ it('renders icon', () => {
+ expect(vm.$el.querySelector('.ic-doc-new')).not.toBe(null);
+ });
+
+ it('emits click event', () => {
+ vm.$el.click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('click');
+ });
+
+ it('hides label if showLabel is false', done => {
+ vm.showLabel = false;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.textContent).not.toContain('Testing');
+
+ done();
+ });
+ });
+
+ describe('tooltipTitle', () => {
+ it('returns empty string when showLabel is true', () => {
+ expect(vm.tooltipTitle).toBe('');
+ });
+
+ it('returns label', done => {
+ vm.showLabel = false;
+
+ vm.$nextTick(() => {
+ expect(vm.tooltipTitle).toBe('Testing');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/new_dropdown/index_spec.js b/spec/frontend/ide/components/new_dropdown/index_spec.js
new file mode 100644
index 00000000000..00781c16609
--- /dev/null
+++ b/spec/frontend/ide/components/new_dropdown/index_spec.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import store from '~/ide/stores';
+import newDropdown from '~/ide/components/new_dropdown/index.vue';
+import { resetStore } from '../../helpers';
+
+describe('new dropdown component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const component = Vue.extend(newDropdown);
+
+ vm = createComponentWithStore(component, store, {
+ branch: 'master',
+ path: '',
+ mouseOver: false,
+ type: 'tree',
+ });
+
+ vm.$store.state.currentProjectId = 'abcproject';
+ vm.$store.state.path = '';
+ vm.$store.state.trees['abcproject/mybranch'] = {
+ tree: [],
+ };
+
+ vm.$mount();
+
+ jest.spyOn(vm.$refs.newModal, 'open').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders new file, upload and new directory links', () => {
+ const buttons = vm.$el.querySelectorAll('.dropdown-menu button');
+
+ expect(buttons[0].textContent.trim()).toBe('New file');
+ expect(buttons[1].textContent.trim()).toBe('Upload file');
+ expect(buttons[2].textContent.trim()).toBe('New directory');
+ });
+
+ describe('createNewItem', () => {
+ it('opens modal for a blob when new file is clicked', () => {
+ vm.$el.querySelectorAll('.dropdown-menu button')[0].click();
+
+ expect(vm.$refs.newModal.open).toHaveBeenCalledWith('blob', '');
+ });
+
+ it('opens modal for a tree when new directory is clicked', () => {
+ vm.$el.querySelectorAll('.dropdown-menu button')[2].click();
+
+ expect(vm.$refs.newModal.open).toHaveBeenCalledWith('tree', '');
+ });
+ });
+
+ describe('isOpen', () => {
+ it('scrolls dropdown into view', done => {
+ jest.spyOn(vm.$refs.dropdownMenu, 'scrollIntoView').mockImplementation(() => {});
+
+ vm.isOpen = true;
+
+ setImmediate(() => {
+ expect(vm.$refs.dropdownMenu.scrollIntoView).toHaveBeenCalledWith({
+ block: 'nearest',
+ });
+
+ done();
+ });
+ });
+ });
+
+ describe('delete entry', () => {
+ it('calls delete action', () => {
+ jest.spyOn(vm, 'deleteEntry').mockImplementation(() => {});
+
+ vm.$el.querySelectorAll('.dropdown-menu button')[4].click();
+
+ expect(vm.deleteEntry).toHaveBeenCalledWith('');
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js
new file mode 100644
index 00000000000..62a59a76bf4
--- /dev/null
+++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js
@@ -0,0 +1,175 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { createStore } from '~/ide/stores';
+import modal from '~/ide/components/new_dropdown/modal.vue';
+import createFlash from '~/flash';
+
+jest.mock('~/flash');
+
+describe('new file modal component', () => {
+ const Component = Vue.extend(modal);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe.each`
+ entryType | modalTitle | btnTitle | showsFileTemplates
+ ${'tree'} | ${'Create new directory'} | ${'Create directory'} | ${false}
+ ${'blob'} | ${'Create new file'} | ${'Create file'} | ${true}
+ `('$entryType', ({ entryType, modalTitle, btnTitle, showsFileTemplates }) => {
+ beforeEach(done => {
+ const store = createStore();
+
+ vm = createComponentWithStore(Component, store).$mount();
+ vm.open(entryType);
+ vm.name = 'testing';
+
+ vm.$nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.close();
+ });
+
+ it(`sets modal title as ${entryType}`, () => {
+ expect(document.querySelector('.modal-title').textContent.trim()).toBe(modalTitle);
+ });
+
+ it(`sets button label as ${entryType}`, () => {
+ expect(document.querySelector('.btn-success').textContent.trim()).toBe(btnTitle);
+ });
+
+ it(`sets form label as ${entryType}`, () => {
+ expect(document.querySelector('.label-bold').textContent.trim()).toBe('Name');
+ });
+
+ it(`shows file templates: ${showsFileTemplates}`, () => {
+ const templateFilesEl = document.querySelector('.file-templates');
+ expect(Boolean(templateFilesEl)).toBe(showsFileTemplates);
+ });
+ });
+
+ describe('rename entry', () => {
+ beforeEach(() => {
+ const store = createStore();
+ store.state.entries = {
+ 'test-path': {
+ name: 'test',
+ type: 'blob',
+ path: 'test-path',
+ },
+ };
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ it.each`
+ entryType | modalTitle | btnTitle
+ ${'tree'} | ${'Rename folder'} | ${'Rename folder'}
+ ${'blob'} | ${'Rename file'} | ${'Rename file'}
+ `(
+ 'renders title and button for renaming $entryType',
+ ({ entryType, modalTitle, btnTitle }, done) => {
+ vm.$store.state.entries['test-path'].type = entryType;
+ vm.open('rename', 'test-path');
+
+ vm.$nextTick(() => {
+ expect(document.querySelector('.modal-title').textContent.trim()).toBe(modalTitle);
+ expect(document.querySelector('.btn-success').textContent.trim()).toBe(btnTitle);
+
+ done();
+ });
+ },
+ );
+
+ describe('entryName', () => {
+ it('returns entries name', () => {
+ vm.open('rename', 'test-path');
+
+ expect(vm.entryName).toBe('test-path');
+ });
+
+ it('does not reset entryName to its old value if empty', () => {
+ vm.entryName = 'hello';
+ vm.entryName = '';
+
+ expect(vm.entryName).toBe('');
+ });
+ });
+
+ describe('open', () => {
+ it('sets entryName to path provided if modalType is rename', () => {
+ vm.open('rename', 'test-path');
+
+ expect(vm.entryName).toBe('test-path');
+ });
+
+ it("appends '/' to the path if modalType isn't rename", () => {
+ vm.open('blob', 'test-path');
+
+ expect(vm.entryName).toBe('test-path/');
+ });
+
+ it('leaves entryName blank if no path is provided', () => {
+ vm.open('blob');
+
+ expect(vm.entryName).toBe('');
+ });
+ });
+ });
+
+ describe('submitForm', () => {
+ let store;
+
+ beforeEach(() => {
+ store = createStore();
+ store.state.entries = {
+ 'test-path/test': {
+ name: 'test',
+ deleted: false,
+ },
+ };
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ it('throws an error when target entry exists', () => {
+ vm.open('rename', 'test-path/test');
+
+ expect(createFlash).not.toHaveBeenCalled();
+
+ vm.submitForm();
+
+ expect(createFlash).toHaveBeenCalledWith(
+ 'The name "test-path/test" is already taken in this directory.',
+ 'alert',
+ expect.anything(),
+ null,
+ false,
+ true,
+ );
+ });
+
+ it('does not throw error when target entry does not exist', () => {
+ jest.spyOn(vm, 'renameEntry').mockImplementation();
+
+ vm.open('rename', 'test-path/test');
+ vm.entryName = 'test-path/test2';
+ vm.submitForm();
+
+ expect(createFlash).not.toHaveBeenCalled();
+ });
+
+ it('removes leading/trailing found in the new name', () => {
+ vm.open('rename', 'test-path/test');
+
+ vm.entryName = 'test-path /test';
+
+ vm.submitForm();
+
+ expect(vm.entryName).toBe('test-path/test');
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js
new file mode 100644
index 00000000000..a418fdeb572
--- /dev/null
+++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js
@@ -0,0 +1,112 @@
+import Vue from 'vue';
+import createComponent from 'helpers/vue_mount_component_helper';
+import upload from '~/ide/components/new_dropdown/upload.vue';
+
+describe('new dropdown upload', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(upload);
+
+ vm = createComponent(Component, {
+ path: '',
+ });
+
+ vm.entryName = 'testing';
+
+ jest.spyOn(vm, '$emit');
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('openFile', () => {
+ it('calls for each file', () => {
+ const files = ['test', 'test2', 'test3'];
+
+ jest.spyOn(vm, 'readFile').mockImplementation(() => {});
+ jest.spyOn(vm.$refs.fileUpload, 'files', 'get').mockReturnValue(files);
+
+ vm.openFile();
+
+ expect(vm.readFile.mock.calls.length).toBe(3);
+
+ files.forEach((file, i) => {
+ expect(vm.readFile.mock.calls[i]).toEqual([file]);
+ });
+ });
+ });
+
+ describe('readFile', () => {
+ beforeEach(() => {
+ jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => {});
+ });
+
+ it('calls readAsDataURL for all files', () => {
+ const file = {
+ type: 'images/png',
+ };
+
+ vm.readFile(file);
+
+ expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file);
+ });
+ });
+
+ describe('createFile', () => {
+ const textTarget = {
+ result: 'base64,cGxhaW4gdGV4dA==',
+ };
+ const binaryTarget = {
+ result: 'base64,w4I=',
+ };
+ const textFile = new File(['plain text'], 'textFile');
+
+ const binaryFile = {
+ name: 'binaryFile',
+ type: 'image/png',
+ };
+
+ beforeEach(() => {
+ jest.spyOn(FileReader.prototype, 'readAsText');
+ });
+
+ it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', done => {
+ const waitForCreate = new Promise(resolve => vm.$on('create', resolve));
+
+ vm.createFile(textTarget, textFile);
+
+ expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(textFile);
+
+ waitForCreate
+ .then(() => {
+ expect(vm.$emit).toHaveBeenCalledWith('create', {
+ name: textFile.name,
+ type: 'blob',
+ content: 'plain text',
+ base64: false,
+ binary: false,
+ rawPath: '',
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('splits content on base64 if binary', () => {
+ vm.createFile(binaryTarget, binaryFile);
+
+ expect(FileReader.prototype.readAsText).not.toHaveBeenCalledWith(textFile);
+
+ expect(vm.$emit).toHaveBeenCalledWith('create', {
+ name: binaryFile.name,
+ type: 'blob',
+ content: binaryTarget.result.split('base64,')[1],
+ base64: true,
+ 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 11e672b6685..d909a5e478e 100644
--- a/spec/frontend/ide/components/pipelines/list_spec.js
+++ b/spec/frontend/ide/components/pipelines/list_spec.js
@@ -7,10 +7,15 @@ import JobsList from '~/ide/components/jobs/list.vue';
import Tab from '~/vue_shared/components/tabs/tab.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { pipelines } from '../../../../javascripts/ide/mock_data';
+import IDEServices from '~/ide/services';
const localVue = createLocalVue();
localVue.use(Vuex);
+jest.mock('~/ide/services', () => ({
+ pingUsage: jest.fn(),
+}));
+
describe('IDE pipelines list', () => {
let wrapper;
@@ -25,14 +30,18 @@ describe('IDE pipelines list', () => {
};
const fetchLatestPipelineMock = jest.fn();
+ const pingUsageMock = jest.fn();
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({
- getters: { currentProject: () => ({ web_url: 'some/url ' }) },
+ getters: {
+ currentProject: () => ({ web_url: 'some/url ', path_with_namespace: fakeProjectPath }),
+ },
state: {
...defaultRestOfState,
...restOfState,
@@ -46,6 +55,7 @@ describe('IDE pipelines list', () => {
},
actions: {
fetchLatestPipeline: fetchLatestPipelineMock,
+ pingUsage: pingUsageMock,
},
getters: {
jobsCount: () => 1,
@@ -77,6 +87,11 @@ describe('IDE pipelines list', () => {
expect(fetchLatestPipelineMock).toHaveBeenCalled();
});
+ it('pings pipeline usage', () => {
+ createComponent();
+ expect(IDEServices.pingUsage).toHaveBeenCalledWith(fakeProjectPath);
+ });
+
describe('when loading', () => {
let defaultPipelinesLoadingState;
beforeAll(() => {
diff --git a/spec/frontend/ide/components/preview/clientside_spec.js b/spec/frontend/ide/components/preview/clientside_spec.js
index 0cde6fb6060..7b2025f5e9f 100644
--- a/spec/frontend/ide/components/preview/clientside_spec.js
+++ b/spec/frontend/ide/components/preview/clientside_spec.js
@@ -70,14 +70,6 @@ describe('IDE clientside preview', () => {
});
};
- beforeAll(() => {
- jest.useFakeTimers();
- });
-
- afterAll(() => {
- jest.useRealTimers();
- });
-
afterEach(() => {
wrapper.destroy();
});
diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js
index 5ea03eb1593..237be018807 100644
--- a/spec/frontend/ide/components/repo_commit_section_spec.js
+++ b/spec/frontend/ide/components/repo_commit_section_spec.js
@@ -36,7 +36,6 @@ describe('RepoCommitSection', () => {
}),
);
- store.state.rightPanelCollapsed = false;
store.state.currentBranch = 'master';
store.state.changedFiles = [];
store.state.stagedFiles = [{ ...files[0] }, { ...files[1] }];
diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js
new file mode 100644
index 00000000000..82ea73ffbb1
--- /dev/null
+++ b/spec/frontend/ide/components/repo_tab_spec.js
@@ -0,0 +1,185 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import repoTab from '~/ide/components/repo_tab.vue';
+import router from '~/ide/ide_router';
+import { file, resetStore } from '../helpers';
+
+describe('RepoTab', () => {
+ let vm;
+
+ function createComponent(propsData) {
+ const RepoTab = Vue.extend(repoTab);
+
+ return new RepoTab({
+ store,
+ propsData,
+ }).$mount();
+ }
+
+ beforeEach(() => {
+ jest.spyOn(router, 'push').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders a close link and a name link', () => {
+ vm = 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}"]`);
+
+ expect(close.innerHTML).toContain('#close');
+ expect(name.textContent.trim()).toEqual(vm.tab.name);
+ });
+
+ it('does not call openPendingTab when tab is active', done => {
+ vm = createComponent({
+ tab: {
+ ...file(),
+ pending: true,
+ active: true,
+ },
+ });
+
+ jest.spyOn(vm, 'openPendingTab').mockImplementation(() => {});
+
+ vm.$el.click();
+
+ vm.$nextTick(() => {
+ expect(vm.openPendingTab).not.toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('fires clickFile when the link is clicked', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+
+ jest.spyOn(vm, 'clickFile').mockImplementation(() => {});
+
+ vm.$el.click();
+
+ expect(vm.clickFile).toHaveBeenCalledWith(vm.tab);
+ });
+
+ it('calls closeFile when clicking close button', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+
+ jest.spyOn(vm, 'closeFile').mockImplementation(() => {});
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ expect(vm.closeFile).toHaveBeenCalledWith(vm.tab);
+ });
+
+ it('changes icon on hover', done => {
+ const tab = file();
+ tab.changed = true;
+ vm = createComponent({
+ tab,
+ });
+
+ vm.$el.dispatchEvent(new Event('mouseover'));
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.file-modified')).toBeNull();
+
+ vm.$el.dispatchEvent(new Event('mouseout'));
+ })
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(vm.$el.querySelector('.file-modified')).not.toBeNull();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ describe('locked file', () => {
+ let f;
+
+ beforeEach(() => {
+ f = file('locked file');
+ f.file_lock = {
+ user: {
+ name: 'testuser',
+ updated_at: new Date(),
+ },
+ };
+
+ vm = createComponent({
+ tab: f,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders lock icon', () => {
+ expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull();
+ });
+
+ it('renders a tooltip', () => {
+ expect(vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle).toContain(
+ 'Locked by testuser',
+ );
+ });
+ });
+
+ describe('methods', () => {
+ describe('closeTab', () => {
+ it('closes tab if file has changed', done => {
+ const tab = file();
+ tab.changed = true;
+ tab.opened = true;
+ vm = 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();
+
+ vm.$nextTick(() => {
+ expect(tab.opened).toBeFalsy();
+ expect(vm.$store.state.changedFiles.length).toBe(1);
+
+ done();
+ });
+ });
+
+ it('closes tab when clicking close btn', done => {
+ const tab = file('lose');
+ tab.opened = true;
+ vm = createComponent({
+ tab,
+ });
+ vm.$store.state.openFiles.push(tab);
+ vm.$store.state.entries[tab.path] = tab;
+ vm.$store.dispatch('setFileActive', tab.path);
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ vm.$nextTick(() => {
+ expect(tab.opened).toBeFalsy();
+
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_tabs_spec.js b/spec/frontend/ide/components/repo_tabs_spec.js
index 583f71e6121..583f71e6121 100644
--- a/spec/javascripts/ide/components/repo_tabs_spec.js
+++ b/spec/frontend/ide/components/repo_tabs_spec.js
diff --git a/spec/frontend/ide/components/shared/tokened_input_spec.js b/spec/frontend/ide/components/shared/tokened_input_spec.js
new file mode 100644
index 00000000000..e687216bd06
--- /dev/null
+++ b/spec/frontend/ide/components/shared/tokened_input_spec.js
@@ -0,0 +1,133 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import TokenedInput from '~/ide/components/shared/tokened_input.vue';
+
+const TEST_PLACEHOLDER = 'Searching in test';
+const TEST_TOKENS = [
+ { label: 'lorem', id: 1 },
+ { label: 'ipsum', id: 2 },
+ { label: 'dolar', id: 3 },
+];
+const TEST_VALUE = 'lorem';
+
+function getTokenElements(vm) {
+ return Array.from(vm.$el.querySelectorAll('.filtered-search-token button'));
+}
+
+function createBackspaceEvent() {
+ const e = new Event('keyup');
+ e.keyCode = 8;
+ e.which = e.keyCode;
+ e.altKey = false;
+ e.ctrlKey = true;
+ e.shiftKey = false;
+ e.metaKey = false;
+ return e;
+}
+
+describe('IDE shared/TokenedInput', () => {
+ const Component = Vue.extend(TokenedInput);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ tokens: TEST_TOKENS,
+ placeholder: TEST_PLACEHOLDER,
+ value: TEST_VALUE,
+ });
+
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders tokens', () => {
+ const renderedTokens = getTokenElements(vm).map(x => x.textContent.trim());
+
+ expect(renderedTokens).toEqual(TEST_TOKENS.map(x => x.label));
+ });
+
+ it('renders input', () => {
+ expect(vm.$refs.input).toBeTruthy();
+ expect(vm.$refs.input).toHaveValue(TEST_VALUE);
+ });
+
+ it('renders placeholder, when tokens are empty', done => {
+ vm.tokens = [];
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$refs.input).toHaveAttr('placeholder', TEST_PLACEHOLDER);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('triggers "removeToken" on token click', () => {
+ getTokenElements(vm)[0].click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[0]);
+ });
+
+ it('when input triggers backspace event, it calls "onBackspace"', () => {
+ jest.spyOn(vm, 'onBackspace').mockImplementation(() => {});
+
+ vm.$refs.input.dispatchEvent(createBackspaceEvent());
+ vm.$refs.input.dispatchEvent(createBackspaceEvent());
+
+ expect(vm.onBackspace).toHaveBeenCalledTimes(2);
+ });
+
+ it('triggers "removeToken" on backspaces when value is empty', () => {
+ vm.value = '';
+
+ vm.onBackspace();
+
+ expect(vm.$emit).not.toHaveBeenCalled();
+ expect(vm.backspaceCount).toEqual(1);
+
+ vm.onBackspace();
+
+ expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[TEST_TOKENS.length - 1]);
+ expect(vm.backspaceCount).toEqual(0);
+ });
+
+ it('does not trigger "removeToken" on backspaces when value is not empty', () => {
+ vm.onBackspace();
+ vm.onBackspace();
+
+ expect(vm.backspaceCount).toEqual(0);
+ expect(vm.$emit).not.toHaveBeenCalled();
+ });
+
+ it('does not trigger "removeToken" on backspaces when tokens are empty', () => {
+ vm.tokens = [];
+
+ vm.onBackspace();
+ vm.onBackspace();
+
+ expect(vm.backspaceCount).toEqual(0);
+ expect(vm.$emit).not.toHaveBeenCalled();
+ });
+
+ it('triggers "focus" on input focus', () => {
+ vm.$refs.input.dispatchEvent(new Event('focus'));
+
+ expect(vm.$emit).toHaveBeenCalledWith('focus');
+ });
+
+ it('triggers "blur" on input blur', () => {
+ vm.$refs.input.dispatchEvent(new Event('blur'));
+
+ expect(vm.$emit).toHaveBeenCalledWith('blur');
+ });
+
+ it('triggers "input" with value on input change', () => {
+ vm.$refs.input.value = 'something-else';
+ vm.$refs.input.dispatchEvent(new Event('input'));
+
+ expect(vm.$emit).toHaveBeenCalledWith('input', 'something-else');
+ });
+});
diff --git a/spec/frontend/ide/lib/common/model_manager_spec.js b/spec/frontend/ide/lib/common/model_manager_spec.js
new file mode 100644
index 00000000000..08e4ab0f113
--- /dev/null
+++ b/spec/frontend/ide/lib/common/model_manager_spec.js
@@ -0,0 +1,126 @@
+import eventHub from '~/ide/eventhub';
+import ModelManager from '~/ide/lib/common/model_manager';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library model manager', () => {
+ let instance;
+
+ beforeEach(() => {
+ instance = new ModelManager();
+ });
+
+ afterEach(() => {
+ instance.dispose();
+ });
+
+ describe('addModel', () => {
+ it('caches model', () => {
+ instance.addModel(file());
+
+ expect(instance.models.size).toBe(1);
+ });
+
+ it('caches model by file path', () => {
+ const f = file('path-name');
+ instance.addModel(f);
+
+ expect(instance.models.keys().next().value).toBe(f.key);
+ });
+
+ it('adds model into disposable', () => {
+ jest.spyOn(instance.disposable, 'add');
+
+ instance.addModel(file());
+
+ expect(instance.disposable.add).toHaveBeenCalled();
+ });
+
+ it('returns cached model', () => {
+ jest.spyOn(instance.models, 'get');
+
+ instance.addModel(file());
+ instance.addModel(file());
+
+ expect(instance.models.get).toHaveBeenCalled();
+ });
+
+ it('adds eventHub listener', () => {
+ const f = file();
+ jest.spyOn(eventHub, '$on');
+
+ instance.addModel(f);
+
+ expect(eventHub.$on).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${f.key}`,
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('hasCachedModel', () => {
+ it('returns false when no models exist', () => {
+ expect(instance.hasCachedModel('path')).toBeFalsy();
+ });
+
+ it('returns true when model exists', () => {
+ const f = file('path-name');
+
+ instance.addModel(f);
+
+ expect(instance.hasCachedModel(f.key)).toBeTruthy();
+ });
+ });
+
+ describe('getModel', () => {
+ it('returns cached model', () => {
+ instance.addModel(file('path-name'));
+
+ expect(instance.getModel('path-name')).not.toBeNull();
+ });
+ });
+
+ describe('removeCachedModel', () => {
+ let f;
+
+ beforeEach(() => {
+ f = file();
+
+ instance.addModel(f);
+ });
+
+ it('clears cached model', () => {
+ instance.removeCachedModel(f);
+
+ expect(instance.models.size).toBe(0);
+ });
+
+ it('removes eventHub listener', () => {
+ jest.spyOn(eventHub, '$off');
+
+ instance.removeCachedModel(f);
+
+ expect(eventHub.$off).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${f.key}`,
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('dispose', () => {
+ it('clears cached models', () => {
+ instance.addModel(file());
+
+ instance.dispose();
+
+ expect(instance.models.size).toBe(0);
+ });
+
+ it('calls disposable dispose', () => {
+ jest.spyOn(instance.disposable, 'dispose');
+
+ instance.dispose();
+
+ expect(instance.disposable.dispose).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/common/model_spec.js b/spec/frontend/ide/lib/common/model_spec.js
new file mode 100644
index 00000000000..2ef2f0da6da
--- /dev/null
+++ b/spec/frontend/ide/lib/common/model_spec.js
@@ -0,0 +1,137 @@
+import eventHub from '~/ide/eventhub';
+import Model from '~/ide/lib/common/model';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library model', () => {
+ let model;
+
+ beforeEach(() => {
+ jest.spyOn(eventHub, '$on');
+
+ const f = file('path');
+ f.mrChange = { diff: 'ABC' };
+ f.baseRaw = 'test';
+ model = new Model(f);
+ });
+
+ afterEach(() => {
+ model.dispose();
+ });
+
+ it('creates original model & base model & new model', () => {
+ expect(model.originalModel).not.toBeNull();
+ expect(model.model).not.toBeNull();
+ expect(model.baseModel).not.toBeNull();
+
+ expect(model.originalModel.uri.path).toBe('original/path--path');
+ expect(model.model.uri.path).toBe('path--path');
+ expect(model.baseModel.uri.path).toBe('target/path--path');
+ });
+
+ it('creates model with head file to compare against', () => {
+ const f = file('path');
+ model.dispose();
+
+ model = new Model(f, {
+ ...f,
+ content: '123 testing',
+ });
+
+ expect(model.head).not.toBeNull();
+ expect(model.getOriginalModel().getValue()).toBe('123 testing');
+ });
+
+ it('adds eventHub listener', () => {
+ expect(eventHub.$on).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${model.file.key}`,
+ expect.anything(),
+ );
+ });
+
+ describe('path', () => {
+ it('returns file path', () => {
+ expect(model.path).toBe(model.file.key);
+ });
+ });
+
+ describe('getModel', () => {
+ it('returns model', () => {
+ expect(model.getModel()).toBe(model.model);
+ });
+ });
+
+ describe('getOriginalModel', () => {
+ it('returns original model', () => {
+ expect(model.getOriginalModel()).toBe(model.originalModel);
+ });
+ });
+
+ describe('getBaseModel', () => {
+ it('returns base model', () => {
+ expect(model.getBaseModel()).toBe(model.baseModel);
+ });
+ });
+
+ describe('setValue', () => {
+ it('updates models value', () => {
+ model.setValue('testing 123');
+
+ expect(model.getModel().getValue()).toBe('testing 123');
+ });
+ });
+
+ describe('onChange', () => {
+ it('calls callback on change', done => {
+ const spy = jest.fn();
+ model.onChange(spy);
+
+ model.getModel().setValue('123');
+
+ setImmediate(() => {
+ expect(spy).toHaveBeenCalledWith(model, expect.anything());
+ done();
+ });
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposable dispose', () => {
+ jest.spyOn(model.disposable, 'dispose');
+
+ model.dispose();
+
+ expect(model.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('clears events', () => {
+ model.onChange(() => {});
+
+ expect(model.events.size).toBe(1);
+
+ model.dispose();
+
+ expect(model.events.size).toBe(0);
+ });
+
+ it('removes eventHub listener', () => {
+ jest.spyOn(eventHub, '$off');
+
+ model.dispose();
+
+ expect(eventHub.$off).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${model.file.key}`,
+ expect.anything(),
+ );
+ });
+
+ it('calls onDispose callback', () => {
+ const disposeSpy = jest.fn();
+
+ model.onDispose(disposeSpy);
+
+ model.dispose();
+
+ expect(disposeSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/decorations/controller_spec.js b/spec/frontend/ide/lib/decorations/controller_spec.js
new file mode 100644
index 00000000000..4556fc9d646
--- /dev/null
+++ b/spec/frontend/ide/lib/decorations/controller_spec.js
@@ -0,0 +1,143 @@
+import Editor from '~/ide/lib/editor';
+import DecorationsController from '~/ide/lib/decorations/controller';
+import Model from '~/ide/lib/common/model';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library decorations controller', () => {
+ let editorInstance;
+ let controller;
+ let model;
+
+ beforeEach(() => {
+ editorInstance = Editor.create();
+ editorInstance.createInstance(document.createElement('div'));
+
+ controller = new DecorationsController(editorInstance);
+ model = new Model(file('path'));
+ });
+
+ afterEach(() => {
+ model.dispose();
+ editorInstance.dispose();
+ controller.dispose();
+ });
+
+ describe('getAllDecorationsForModel', () => {
+ it('returns empty array when no decorations exist for model', () => {
+ const decorations = controller.getAllDecorationsForModel(model);
+
+ expect(decorations).toEqual([]);
+ });
+
+ it('returns decorations by model URL', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ const decorations = controller.getAllDecorationsForModel(model);
+
+ expect(decorations[0]).toEqual({ decoration: 'decorationValue' });
+ });
+ });
+
+ describe('addDecorations', () => {
+ it('caches decorations in a new map', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.decorations.size).toBe(1);
+ });
+
+ it('does not create new cache model', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]);
+
+ expect(controller.decorations.size).toBe(1);
+ });
+
+ it('caches decorations by model URL', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.decorations.size).toBe(1);
+ expect(controller.decorations.keys().next().value).toBe('gitlab:path--path');
+ });
+
+ it('calls decorate method', () => {
+ jest.spyOn(controller, 'decorate').mockImplementation(() => {});
+
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.decorate).toHaveBeenCalled();
+ });
+ });
+
+ describe('decorate', () => {
+ it('sets decorations on editor instance', () => {
+ jest.spyOn(controller.editor.instance, 'deltaDecorations').mockImplementation(() => {});
+
+ controller.decorate(model);
+
+ expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []);
+ });
+
+ it('caches decorations', () => {
+ jest.spyOn(controller.editor.instance, 'deltaDecorations').mockReturnValue([]);
+
+ controller.decorate(model);
+
+ expect(controller.editorDecorations.size).toBe(1);
+ });
+
+ it('caches decorations by model URL', () => {
+ jest.spyOn(controller.editor.instance, 'deltaDecorations').mockReturnValue([]);
+
+ controller.decorate(model);
+
+ expect(controller.editorDecorations.keys().next().value).toBe('gitlab:path--path');
+ });
+ });
+
+ describe('dispose', () => {
+ it('clears cached decorations', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ controller.dispose();
+
+ expect(controller.decorations.size).toBe(0);
+ });
+
+ it('clears cached editorDecorations', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ controller.dispose();
+
+ expect(controller.editorDecorations.size).toBe(0);
+ });
+ });
+
+ describe('hasDecorations', () => {
+ it('returns true when decorations are cached', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.hasDecorations(model)).toBe(true);
+ });
+
+ it('returns false when no model decorations exist', () => {
+ expect(controller.hasDecorations(model)).toBe(false);
+ });
+ });
+
+ describe('removeDecorations', () => {
+ beforeEach(() => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+ controller.decorate(model);
+ });
+
+ it('removes cached decorations', () => {
+ expect(controller.decorations.size).not.toBe(0);
+ expect(controller.editorDecorations.size).not.toBe(0);
+
+ controller.removeDecorations(model);
+
+ expect(controller.decorations.size).toBe(0);
+ expect(controller.editorDecorations.size).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/diff/controller_spec.js b/spec/frontend/ide/lib/diff/controller_spec.js
new file mode 100644
index 00000000000..0b33a4c6ad6
--- /dev/null
+++ b/spec/frontend/ide/lib/diff/controller_spec.js
@@ -0,0 +1,215 @@
+import { Range } from 'monaco-editor';
+import Editor from '~/ide/lib/editor';
+import ModelManager from '~/ide/lib/common/model_manager';
+import DecorationsController from '~/ide/lib/decorations/controller';
+import DirtyDiffController, { getDiffChangeType, getDecorator } from '~/ide/lib/diff/controller';
+import { computeDiff } from '~/ide/lib/diff/diff';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library dirty diff controller', () => {
+ let editorInstance;
+ let controller;
+ let modelManager;
+ let decorationsController;
+ let model;
+
+ beforeEach(() => {
+ editorInstance = Editor.create();
+ editorInstance.createInstance(document.createElement('div'));
+
+ modelManager = new ModelManager();
+ decorationsController = new DecorationsController(editorInstance);
+
+ model = modelManager.addModel(file('path'));
+
+ controller = new DirtyDiffController(modelManager, decorationsController);
+ });
+
+ afterEach(() => {
+ controller.dispose();
+ model.dispose();
+ decorationsController.dispose();
+ editorInstance.dispose();
+ });
+
+ describe('getDiffChangeType', () => {
+ ['added', 'removed', 'modified'].forEach(type => {
+ it(`returns ${type}`, () => {
+ const change = {
+ [type]: true,
+ };
+
+ expect(getDiffChangeType(change)).toBe(type);
+ });
+ });
+ });
+
+ describe('getDecorator', () => {
+ ['added', 'removed', 'modified'].forEach(type => {
+ it(`returns with linesDecorationsClassName for ${type}`, () => {
+ const change = {
+ [type]: true,
+ };
+
+ expect(getDecorator(change).options.linesDecorationsClassName).toBe(
+ `dirty-diff dirty-diff-${type}`,
+ );
+ });
+
+ it('returns with line numbers', () => {
+ const change = {
+ lineNumber: 1,
+ endLineNumber: 2,
+ [type]: true,
+ };
+
+ const { range } = getDecorator(change);
+
+ expect(range.startLineNumber).toBe(1);
+ expect(range.endLineNumber).toBe(2);
+ expect(range.startColumn).toBe(1);
+ expect(range.endColumn).toBe(1);
+ });
+ });
+ });
+
+ describe('attachModel', () => {
+ it('adds change event callback', () => {
+ jest.spyOn(model, 'onChange').mockImplementation(() => {});
+
+ controller.attachModel(model);
+
+ expect(model.onChange).toHaveBeenCalled();
+ });
+
+ it('adds dispose event callback', () => {
+ jest.spyOn(model, 'onDispose').mockImplementation(() => {});
+
+ controller.attachModel(model);
+
+ expect(model.onDispose).toHaveBeenCalled();
+ });
+
+ it('calls throttledComputeDiff on change', () => {
+ jest.spyOn(controller, 'throttledComputeDiff').mockImplementation(() => {});
+
+ controller.attachModel(model);
+
+ model.getModel().setValue('123');
+
+ expect(controller.throttledComputeDiff).toHaveBeenCalled();
+ });
+
+ it('caches model', () => {
+ controller.attachModel(model);
+
+ expect(controller.models.has(model.url)).toBe(true);
+ });
+ });
+
+ describe('computeDiff', () => {
+ it('posts to worker', () => {
+ jest.spyOn(controller.dirtyDiffWorker, 'postMessage').mockImplementation(() => {});
+
+ controller.computeDiff(model);
+
+ expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({
+ path: model.path,
+ originalContent: '',
+ newContent: '',
+ });
+ });
+ });
+
+ describe('reDecorate', () => {
+ it('calls computeDiff when no decorations are cached', () => {
+ jest.spyOn(controller, 'computeDiff').mockImplementation(() => {});
+
+ controller.reDecorate(model);
+
+ expect(controller.computeDiff).toHaveBeenCalledWith(model);
+ });
+
+ it('calls decorate when decorations are cached', () => {
+ jest.spyOn(controller.decorationsController, 'decorate').mockImplementation(() => {});
+
+ controller.decorationsController.decorations.set(model.url, 'test');
+
+ controller.reDecorate(model);
+
+ expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model);
+ });
+ });
+
+ describe('decorate', () => {
+ it('adds decorations into decorations controller', () => {
+ jest.spyOn(controller.decorationsController, 'addDecorations').mockImplementation(() => {});
+
+ controller.decorate({ data: { changes: [], path: model.path } });
+
+ expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith(
+ model,
+ 'dirtyDiff',
+ expect.anything(),
+ );
+ });
+
+ it('adds decorations into editor', () => {
+ const spy = jest.spyOn(controller.decorationsController.editor.instance, 'deltaDecorations');
+
+ controller.decorate({
+ data: { changes: computeDiff('123', '1234'), path: model.path },
+ });
+
+ expect(spy).toHaveBeenCalledWith(
+ [],
+ [
+ {
+ range: new Range(1, 1, 1, 1),
+ options: {
+ isWholeLine: true,
+ linesDecorationsClassName: 'dirty-diff dirty-diff-modified',
+ },
+ },
+ ],
+ );
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposable dispose', () => {
+ jest.spyOn(controller.disposable, 'dispose');
+
+ controller.dispose();
+
+ expect(controller.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('terminates worker', () => {
+ jest.spyOn(controller.dirtyDiffWorker, 'terminate');
+
+ controller.dispose();
+
+ expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled();
+ });
+
+ it('removes worker event listener', () => {
+ jest.spyOn(controller.dirtyDiffWorker, 'removeEventListener');
+
+ controller.dispose();
+
+ expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith(
+ 'message',
+ expect.anything(),
+ );
+ });
+
+ it('clears cached models', () => {
+ controller.attachModel(model);
+
+ model.dispose();
+
+ expect(controller.models.size).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/editor_spec.js b/spec/frontend/ide/lib/editor_spec.js
new file mode 100644
index 00000000000..36d4c3c26ee
--- /dev/null
+++ b/spec/frontend/ide/lib/editor_spec.js
@@ -0,0 +1,302 @@
+import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor';
+import Editor from '~/ide/lib/editor';
+import { defaultEditorOptions } from '~/ide/lib/editor_options';
+import { file } from '../helpers';
+
+describe('Multi-file editor library', () => {
+ let instance;
+ let el;
+ let holder;
+
+ const setNodeOffsetWidth = val => {
+ Object.defineProperty(instance.instance.getDomNode(), 'offsetWidth', {
+ get() {
+ return val;
+ },
+ });
+ };
+
+ beforeEach(() => {
+ el = document.createElement('div');
+ holder = document.createElement('div');
+ el.appendChild(holder);
+
+ document.body.appendChild(el);
+
+ instance = Editor.create();
+ });
+
+ afterEach(() => {
+ instance.modelManager.dispose();
+ instance.dispose();
+ Editor.editorInstance = null;
+
+ el.remove();
+ });
+
+ it('creates instance of editor', () => {
+ expect(Editor.editorInstance).not.toBeNull();
+ });
+
+ it('creates instance returns cached instance', () => {
+ expect(Editor.create()).toEqual(instance);
+ });
+
+ describe('createInstance', () => {
+ it('creates editor instance', () => {
+ jest.spyOn(monacoEditor, 'create');
+
+ instance.createInstance(holder);
+
+ expect(monacoEditor.create).toHaveBeenCalled();
+ });
+
+ it('creates dirty diff controller', () => {
+ instance.createInstance(holder);
+
+ expect(instance.dirtyDiffController).not.toBeNull();
+ });
+
+ it('creates model manager', () => {
+ instance.createInstance(holder);
+
+ expect(instance.modelManager).not.toBeNull();
+ });
+ });
+
+ describe('createDiffInstance', () => {
+ it('creates editor instance', () => {
+ jest.spyOn(monacoEditor, 'createDiffEditor');
+
+ instance.createDiffInstance(holder);
+
+ expect(monacoEditor.createDiffEditor).toHaveBeenCalledWith(holder, {
+ ...defaultEditorOptions,
+ quickSuggestions: false,
+ occurrencesHighlight: false,
+ renderSideBySide: false,
+ readOnly: true,
+ renderLineHighlight: 'all',
+ hideCursorInOverviewRuler: false,
+ });
+ });
+ });
+
+ describe('createModel', () => {
+ it('calls model manager addModel', () => {
+ jest.spyOn(instance.modelManager, 'addModel').mockImplementation(() => {});
+
+ instance.createModel('FILE');
+
+ expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE', null);
+ });
+ });
+
+ describe('attachModel', () => {
+ let model;
+
+ beforeEach(() => {
+ instance.createInstance(document.createElement('div'));
+
+ model = instance.createModel(file());
+ });
+
+ it('sets the current model on the instance', () => {
+ instance.attachModel(model);
+
+ expect(instance.currentModel).toBe(model);
+ });
+
+ it('attaches the model to the current instance', () => {
+ jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {});
+
+ instance.attachModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel());
+ });
+
+ it('sets original & modified when diff editor', () => {
+ jest.spyOn(instance.instance, 'getEditorType').mockReturnValue('vs.editor.IDiffEditor');
+ jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {});
+
+ instance.attachModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith({
+ original: model.getOriginalModel(),
+ modified: model.getModel(),
+ });
+ });
+
+ it('attaches the model to the dirty diff controller', () => {
+ jest.spyOn(instance.dirtyDiffController, 'attachModel').mockImplementation(() => {});
+
+ instance.attachModel(model);
+
+ expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith(model);
+ });
+
+ it('re-decorates with the dirty diff controller', () => {
+ jest.spyOn(instance.dirtyDiffController, 'reDecorate').mockImplementation(() => {});
+
+ instance.attachModel(model);
+
+ expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith(model);
+ });
+ });
+
+ describe('attachMergeRequestModel', () => {
+ let model;
+
+ beforeEach(() => {
+ instance.createDiffInstance(document.createElement('div'));
+
+ const f = file();
+ f.mrChanges = { diff: 'ABC' };
+ f.baseRaw = 'testing';
+
+ model = instance.createModel(f);
+ });
+
+ it('sets original & modified', () => {
+ jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {});
+
+ instance.attachMergeRequestModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith({
+ original: model.getBaseModel(),
+ modified: model.getModel(),
+ });
+ });
+ });
+
+ describe('clearEditor', () => {
+ it('resets the editor model', () => {
+ instance.createInstance(document.createElement('div'));
+
+ jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {});
+
+ instance.clearEditor();
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith(null);
+ });
+ });
+
+ describe('languages', () => {
+ it('registers custom languages defined with Monaco', () => {
+ expect(monacoLanguages.getLanguages()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: 'vue',
+ }),
+ ]),
+ );
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposble dispose method', () => {
+ jest.spyOn(instance.disposable, 'dispose');
+
+ instance.dispose();
+
+ expect(instance.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('resets instance', () => {
+ instance.createInstance(document.createElement('div'));
+
+ expect(instance.instance).not.toBeNull();
+
+ instance.dispose();
+
+ expect(instance.instance).toBeNull();
+ });
+
+ it('does not dispose modelManager', () => {
+ jest.spyOn(instance.modelManager, 'dispose').mockImplementation(() => {});
+
+ instance.dispose();
+
+ expect(instance.modelManager.dispose).not.toHaveBeenCalled();
+ });
+
+ it('does not dispose decorationsController', () => {
+ jest.spyOn(instance.decorationsController, 'dispose').mockImplementation(() => {});
+
+ instance.dispose();
+
+ expect(instance.decorationsController.dispose).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('updateDiffView', () => {
+ describe('edit mode', () => {
+ it('does not update options', () => {
+ instance.createInstance(holder);
+
+ jest.spyOn(instance.instance, 'updateOptions').mockImplementation(() => {});
+
+ instance.updateDiffView();
+
+ expect(instance.instance.updateOptions).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('diff mode', () => {
+ beforeEach(() => {
+ instance.createDiffInstance(holder);
+
+ jest.spyOn(instance.instance, 'updateOptions');
+ });
+
+ it('sets renderSideBySide to false if el is less than 700 pixels', () => {
+ setNodeOffsetWidth(600);
+
+ expect(instance.instance.updateOptions).not.toHaveBeenCalledWith({
+ renderSideBySide: false,
+ });
+ });
+
+ it('sets renderSideBySide to false if el is more than 700 pixels', () => {
+ setNodeOffsetWidth(800);
+
+ expect(instance.instance.updateOptions).not.toHaveBeenCalledWith({
+ renderSideBySide: true,
+ });
+ });
+ });
+ });
+
+ describe('isDiffEditorType', () => {
+ it('returns true when diff editor', () => {
+ instance.createDiffInstance(holder);
+
+ expect(instance.isDiffEditorType).toBe(true);
+ });
+
+ it('returns false when not diff editor', () => {
+ instance.createInstance(holder);
+
+ expect(instance.isDiffEditorType).toBe(false);
+ });
+ });
+
+ it('sets quickSuggestions to false when language is markdown', () => {
+ instance.createInstance(holder);
+
+ jest.spyOn(instance.instance, 'updateOptions');
+
+ const model = instance.createModel({
+ ...file(),
+ key: 'index.md',
+ path: 'index.md',
+ });
+
+ instance.attachModel(model);
+
+ expect(instance.instance.updateOptions).toHaveBeenCalledWith({
+ readOnly: false,
+ quickSuggestions: false,
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/languages/vue_spec.js b/spec/frontend/ide/lib/languages/vue_spec.js
new file mode 100644
index 00000000000..3d8784c1436
--- /dev/null
+++ b/spec/frontend/ide/lib/languages/vue_spec.js
@@ -0,0 +1,92 @@
+import { editor } from 'monaco-editor';
+import { registerLanguages } from '~/ide/utils';
+import vue from '~/ide/lib/languages/vue';
+
+// This file only tests syntax specific to vue. This does not test existing syntaxes
+// of html, javascript, css and handlebars, which vue files extend.
+describe('tokenization for .vue files', () => {
+ beforeEach(() => {
+ registerLanguages(vue);
+ });
+
+ test.each([
+ [
+ '<div v-if="something">content</div>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 4, type: '' },
+ { language: 'vue', offset: 5, type: 'variable' },
+ { language: 'vue', offset: 21, type: 'delimiter.html' },
+ { language: 'vue', offset: 22, type: '' },
+ { language: 'vue', offset: 29, type: 'delimiter.html' },
+ { language: 'vue', offset: 31, type: 'tag.html' },
+ { language: 'vue', offset: 34, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<input :placeholder="placeholder">',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 6, type: '' },
+ { language: 'vue', offset: 7, type: 'variable' },
+ { language: 'vue', offset: 33, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<gl-modal @ok="submitForm()"></gl-modal>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 3, type: 'attribute.name' },
+ { language: 'vue', offset: 9, type: '' },
+ { language: 'vue', offset: 10, type: 'variable' },
+ { language: 'vue', offset: 28, type: 'delimiter.html' },
+ { language: 'vue', offset: 31, type: 'tag.html' },
+ { language: 'vue', offset: 33, type: 'attribute.name' },
+ { language: 'vue', offset: 39, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<a v-on:click.stop="doSomething">...</a>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 2, type: '' },
+ { language: 'vue', offset: 3, type: 'variable' },
+ { language: 'vue', offset: 32, type: 'delimiter.html' },
+ { language: 'vue', offset: 33, type: '' },
+ { language: 'vue', offset: 36, type: 'delimiter.html' },
+ { language: 'vue', offset: 38, type: 'tag.html' },
+ { language: 'vue', offset: 39, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<a @[event]="doSomething">...</a>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 2, type: '' },
+ { language: 'vue', offset: 3, type: 'variable' },
+ { language: 'vue', offset: 25, type: 'delimiter.html' },
+ { language: 'vue', offset: 26, type: '' },
+ { language: 'vue', offset: 29, type: 'delimiter.html' },
+ { language: 'vue', offset: 31, type: 'tag.html' },
+ { language: 'vue', offset: 32, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ ])('%s', (string, tokens) => {
+ expect(editor.tokenize(string, 'vue')).toEqual(tokens);
+ });
+});
diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js
index 658ad37d7f2..3cb6e064aa2 100644
--- a/spec/frontend/ide/services/index_spec.js
+++ b/spec/frontend/ide/services/index_spec.js
@@ -221,4 +221,67 @@ describe('IDE services', () => {
});
});
});
+
+ describe('getFiles', () => {
+ let mock;
+ let relativeUrlRoot;
+ const TEST_RELATIVE_URL_ROOT = 'blah-blah';
+
+ beforeEach(() => {
+ jest.spyOn(axios, 'get');
+ relativeUrlRoot = gon.relative_url_root;
+ gon.relative_url_root = TEST_RELATIVE_URL_ROOT;
+
+ mock = new MockAdapter(axios);
+
+ mock
+ .onGet(`${TEST_RELATIVE_URL_ROOT}/${TEST_PROJECT_ID}/-/files/${TEST_COMMIT_SHA}`)
+ .reply(200, [TEST_FILE_PATH]);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ gon.relative_url_root = relativeUrlRoot;
+ });
+
+ it('initates the api call based on the passed path and commit hash', () => {
+ return services.getFiles(TEST_PROJECT_ID, TEST_COMMIT_SHA).then(({ data }) => {
+ expect(axios.get).toHaveBeenCalledWith(
+ `${gon.relative_url_root}/${TEST_PROJECT_ID}/-/files/${TEST_COMMIT_SHA}`,
+ expect.any(Object),
+ );
+ expect(data).toEqual([TEST_FILE_PATH]);
+ });
+ });
+ });
+
+ describe('pingUsage', () => {
+ let mock;
+ let relativeUrlRoot;
+ const TEST_RELATIVE_URL_ROOT = 'blah-blah';
+
+ beforeEach(() => {
+ jest.spyOn(axios, 'post');
+ relativeUrlRoot = gon.relative_url_root;
+ gon.relative_url_root = TEST_RELATIVE_URL_ROOT;
+
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ gon.relative_url_root = relativeUrlRoot;
+ });
+
+ it('posts to usage endpoint', () => {
+ const TEST_PROJECT_PATH = 'foo/bar';
+ const axiosURL = `${TEST_RELATIVE_URL_ROOT}/${TEST_PROJECT_PATH}/usage_ping/web_ide_pipelines_count`;
+
+ mock.onPost(axiosURL).reply(200);
+
+ return services.pingUsage(TEST_PROJECT_PATH).then(() => {
+ expect(axios.post).toHaveBeenCalledWith(axiosURL);
+ });
+ });
+ });
});
diff --git a/spec/frontend/ide/stores/mutations_spec.js b/spec/frontend/ide/stores/mutations_spec.js
index 5d0fe35a10e..2eca9acb8d8 100644
--- a/spec/frontend/ide/stores/mutations_spec.js
+++ b/spec/frontend/ide/stores/mutations_spec.js
@@ -55,30 +55,6 @@ describe('Multi-file store mutations', () => {
});
});
- describe('SET_LEFT_PANEL_COLLAPSED', () => {
- it('sets left panel collapsed', () => {
- mutations.SET_LEFT_PANEL_COLLAPSED(localState, true);
-
- expect(localState.leftPanelCollapsed).toBeTruthy();
-
- mutations.SET_LEFT_PANEL_COLLAPSED(localState, false);
-
- expect(localState.leftPanelCollapsed).toBeFalsy();
- });
- });
-
- describe('SET_RIGHT_PANEL_COLLAPSED', () => {
- it('sets right panel collapsed', () => {
- mutations.SET_RIGHT_PANEL_COLLAPSED(localState, true);
-
- expect(localState.rightPanelCollapsed).toBeTruthy();
-
- mutations.SET_RIGHT_PANEL_COLLAPSED(localState, false);
-
- expect(localState.rightPanelCollapsed).toBeFalsy();
- });
- });
-
describe('CLEAR_STAGED_CHANGES', () => {
it('clears stagedFiles array', () => {
localState.stagedFiles.push('a');
@@ -339,23 +315,6 @@ describe('Multi-file store mutations', () => {
});
});
- describe('OPEN_NEW_ENTRY_MODAL', () => {
- it('sets entryModal', () => {
- localState.entries.testPath = file();
-
- mutations.OPEN_NEW_ENTRY_MODAL(localState, {
- type: 'test',
- path: 'testPath',
- });
-
- expect(localState.entryModal).toEqual({
- type: 'test',
- path: 'testPath',
- entry: localState.entries.testPath,
- });
- });
- });
-
describe('RENAME_ENTRY', () => {
beforeEach(() => {
localState.trees = {
diff --git a/spec/frontend/ide/stores/utils_spec.js b/spec/frontend/ide/stores/utils_spec.js
index 90f2644de62..b87f6c1f05a 100644
--- a/spec/frontend/ide/stores/utils_spec.js
+++ b/spec/frontend/ide/stores/utils_spec.js
@@ -685,4 +685,75 @@ describe('Multi-file store utils', () => {
});
});
});
+
+ describe('extractMarkdownImagesFromEntries', () => {
+ let mdFile;
+ let entries;
+
+ beforeEach(() => {
+ const img = { content: '/base64/encoded/image+' };
+ mdFile = { path: 'path/to/some/directory/myfile.md' };
+ entries = {
+ // invalid (or lack of) extensions are also supported as long as there's
+ // a real image inside and can go into an <img> tag's `src` and the browser
+ // can render it
+ img,
+ 'img.js': img,
+ 'img.png': img,
+ 'img.with.many.dots.png': img,
+ 'path/to/img.gif': img,
+ 'path/to/some/img.jpg': img,
+ 'path/to/some/img 1/img.png': img,
+ 'path/to/some/directory/img.png': img,
+ 'path/to/some/directory/img 1.png': img,
+ };
+ });
+
+ it.each`
+ markdownBefore | ext | imgAlt | imgTitle
+ ${'* ![img](/img)'} | ${'jpeg'} | ${'img'} | ${undefined}
+ ${'* ![img](/img.js)'} | ${'js'} | ${'img'} | ${undefined}
+ ${'* ![img](img.png)'} | ${'png'} | ${'img'} | ${undefined}
+ ${'* ![img](./img.png)'} | ${'png'} | ${'img'} | ${undefined}
+ ${'* ![with spaces](../img 1/img.png)'} | ${'png'} | ${'with spaces'} | ${undefined}
+ ${'* ![img](../../img.gif " title ")'} | ${'gif'} | ${'img'} | ${' title '}
+ ${'* ![img](../img.jpg)'} | ${'jpg'} | ${'img'} | ${undefined}
+ ${'* ![img](/img.png "title")'} | ${'png'} | ${'img'} | ${'title'}
+ ${'* ![img](/img.with.many.dots.png)'} | ${'png'} | ${'img'} | ${undefined}
+ ${'* ![img](img 1.png)'} | ${'png'} | ${'img'} | ${undefined}
+ ${'* ![img](img.png "title here")'} | ${'png'} | ${'img'} | ${'title here'}
+ `(
+ 'correctly transforms markdown with uncommitted images: $markdownBefore',
+ ({ markdownBefore, ext, imgAlt, imgTitle }) => {
+ mdFile.content = markdownBefore;
+
+ expect(utils.extractMarkdownImagesFromEntries(mdFile, entries)).toEqual({
+ content: '* {{gl_md_img_1}}',
+ images: {
+ '{{gl_md_img_1}}': {
+ src: `data:image/${ext};base64,/base64/encoded/image+`,
+ alt: imgAlt,
+ title: imgTitle,
+ },
+ },
+ });
+ },
+ );
+
+ it.each`
+ markdown
+ ${'* ![img](i.png)'}
+ ${'* ![img](img.png invalid title)'}
+ ${'* ![img](img.png "incorrect" "markdown")'}
+ ${'* ![img](https://gitlab.com/logo.png)'}
+ ${'* ![img](https://gitlab.com/some/deep/nested/path/logo.png)'}
+ `("doesn't touch invalid or non-existant images in markdown: $markdown", ({ markdown }) => {
+ mdFile.content = markdown;
+
+ expect(utils.extractMarkdownImagesFromEntries(mdFile, entries)).toEqual({
+ content: markdown,
+ images: {},
+ });
+ });
+ });
});
diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js
index 44eae7eacbe..ea975500e8d 100644
--- a/spec/frontend/ide/utils_spec.js
+++ b/spec/frontend/ide/utils_spec.js
@@ -1,6 +1,7 @@
import { commitItemIconMap } from '~/ide/constants';
-import { getCommitIconMap, isTextFile } from '~/ide/utils';
+import { getCommitIconMap, isTextFile, registerLanguages, trimPathComponents } from '~/ide/utils';
import { decorateData } from '~/ide/stores/utils';
+import { languages } from 'monaco-editor';
describe('WebIDE utils', () => {
describe('isTextFile', () => {
@@ -102,4 +103,93 @@ describe('WebIDE utils', () => {
expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.modified);
});
});
+
+ describe('trimPathComponents', () => {
+ it.each`
+ input | output
+ ${'example path '} | ${'example path'}
+ ${'p/somefile '} | ${'p/somefile'}
+ ${'p /somefile '} | ${'p/somefile'}
+ ${'p/ somefile '} | ${'p/somefile'}
+ ${' p/somefile '} | ${'p/somefile'}
+ ${'p/somefile .md'} | ${'p/somefile .md'}
+ ${'path / to / some/file.doc '} | ${'path/to/some/file.doc'}
+ `('trims all path components in path: "$input"', ({ input, output }) => {
+ expect(trimPathComponents(input)).toEqual(output);
+ });
+ });
+
+ describe('registerLanguages', () => {
+ let langs;
+
+ beforeEach(() => {
+ langs = [
+ {
+ id: 'html',
+ extensions: ['.html'],
+ conf: { comments: { blockComment: ['<!--', '-->'] } },
+ language: { tokenizer: {} },
+ },
+ {
+ id: 'css',
+ extensions: ['.css'],
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ language: { tokenizer: {} },
+ },
+ {
+ id: 'js',
+ extensions: ['.js'],
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ language: { tokenizer: {} },
+ },
+ ];
+
+ jest.spyOn(languages, 'register').mockImplementation(() => {});
+ jest.spyOn(languages, 'setMonarchTokensProvider').mockImplementation(() => {});
+ jest.spyOn(languages, 'setLanguageConfiguration').mockImplementation(() => {});
+ });
+
+ it('registers all the passed languages with Monaco', () => {
+ registerLanguages(...langs);
+
+ expect(languages.register.mock.calls).toEqual([
+ [
+ {
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ extensions: ['.css'],
+ id: 'css',
+ language: { tokenizer: {} },
+ },
+ ],
+ [
+ {
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ extensions: ['.js'],
+ id: 'js',
+ language: { tokenizer: {} },
+ },
+ ],
+ [
+ {
+ conf: { comments: { blockComment: ['<!--', '-->'] } },
+ extensions: ['.html'],
+ id: 'html',
+ language: { tokenizer: {} },
+ },
+ ],
+ ]);
+
+ expect(languages.setMonarchTokensProvider.mock.calls).toEqual([
+ ['css', { tokenizer: {} }],
+ ['js', { tokenizer: {} }],
+ ['html', { tokenizer: {} }],
+ ]);
+
+ expect(languages.setLanguageConfiguration.mock.calls).toEqual([
+ ['css', { comments: { blockComment: ['/*', '*/'] } }],
+ ['js', { comments: { blockComment: ['/*', '*/'] } }],
+ ['html', { comments: { blockComment: ['<!--', '-->'] } }],
+ ]);
+ });
+ });
});
diff --git a/spec/frontend/image_diff/helpers/badge_helper_spec.js b/spec/frontend/image_diff/helpers/badge_helper_spec.js
new file mode 100644
index 00000000000..c970ccc535d
--- /dev/null
+++ b/spec/frontend/image_diff/helpers/badge_helper_spec.js
@@ -0,0 +1,130 @@
+import * as badgeHelper from '~/image_diff/helpers/badge_helper';
+import * as mockData from '../mock_data';
+
+describe('badge helper', () => {
+ const { coordinate, noteId, badgeText, badgeNumber } = mockData;
+ let containerEl;
+ let buttonEl;
+
+ beforeEach(() => {
+ containerEl = document.createElement('div');
+ });
+
+ describe('createImageBadge', () => {
+ beforeEach(() => {
+ buttonEl = badgeHelper.createImageBadge(noteId, coordinate);
+ });
+
+ it('should create button', () => {
+ expect(buttonEl.tagName).toEqual('BUTTON');
+ expect(buttonEl.getAttribute('type')).toEqual('button');
+ });
+
+ it('should set disabled attribute', () => {
+ expect(buttonEl.hasAttribute('disabled')).toEqual(true);
+ });
+
+ it('should set noteId', () => {
+ expect(buttonEl.dataset.noteId).toEqual(noteId);
+ });
+
+ it('should set coordinate', () => {
+ expect(buttonEl.style.left).toEqual(`${coordinate.x}px`);
+ expect(buttonEl.style.top).toEqual(`${coordinate.y}px`);
+ });
+
+ describe('classNames', () => {
+ it('should set .js-image-badge by default', () => {
+ expect(buttonEl.className).toEqual('js-image-badge');
+ });
+
+ it('should add additional class names if parameter is passed', () => {
+ const classNames = ['first-class', 'second-class'];
+ buttonEl = badgeHelper.createImageBadge(noteId, coordinate, classNames);
+
+ expect(buttonEl.className).toEqual(classNames.concat('js-image-badge').join(' '));
+ });
+ });
+ });
+
+ describe('addImageBadge', () => {
+ beforeEach(() => {
+ badgeHelper.addImageBadge(containerEl, {
+ coordinate,
+ badgeText,
+ noteId,
+ });
+ buttonEl = containerEl.querySelector('button');
+ });
+
+ it('should appends button to container', () => {
+ expect(buttonEl).toBeDefined();
+ });
+
+ it('should add badge classes', () => {
+ expect(buttonEl.className).toContain('badge badge-pill');
+ });
+
+ it('should set the badge text', () => {
+ expect(buttonEl.textContent).toEqual(badgeText);
+ });
+
+ it('should set the button coordinates', () => {
+ expect(buttonEl.style.left).toEqual(`${coordinate.x}px`);
+ expect(buttonEl.style.top).toEqual(`${coordinate.y}px`);
+ });
+
+ it('should set the button noteId', () => {
+ expect(buttonEl.dataset.noteId).toEqual(noteId);
+ });
+ });
+
+ describe('addImageCommentBadge', () => {
+ beforeEach(() => {
+ badgeHelper.addImageCommentBadge(containerEl, {
+ coordinate,
+ noteId,
+ });
+ buttonEl = containerEl.querySelector('button');
+ });
+
+ it('should append icon button to container', () => {
+ expect(buttonEl).toBeDefined();
+ });
+
+ it('should create icon comment button', () => {
+ const iconEl = buttonEl.querySelector('svg');
+
+ expect(iconEl).toBeDefined();
+ });
+ });
+
+ describe('addAvatarBadge', () => {
+ let avatarBadgeEl;
+
+ beforeEach(() => {
+ containerEl.innerHTML = `
+ <div id="${noteId}">
+ <div class="badge hidden">
+ </div>
+ </div>
+ `;
+
+ badgeHelper.addAvatarBadge(containerEl, {
+ detail: {
+ noteId,
+ badgeNumber,
+ },
+ });
+ avatarBadgeEl = containerEl.querySelector(`#${noteId} .badge`);
+ });
+
+ it('should update badge number', () => {
+ expect(avatarBadgeEl.textContent).toEqual(badgeNumber.toString());
+ });
+
+ it('should remove hidden class', () => {
+ expect(avatarBadgeEl.classList.contains('hidden')).toEqual(false);
+ });
+ });
+});
diff --git a/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js b/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js
new file mode 100644
index 00000000000..395bb7de362
--- /dev/null
+++ b/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js
@@ -0,0 +1,144 @@
+import * as commentIndicatorHelper from '~/image_diff/helpers/comment_indicator_helper';
+import * as mockData from '../mock_data';
+
+describe('commentIndicatorHelper', () => {
+ const { coordinate } = mockData;
+ let containerEl;
+
+ beforeEach(() => {
+ containerEl = document.createElement('div');
+ });
+
+ describe('addCommentIndicator', () => {
+ let buttonEl;
+
+ beforeEach(() => {
+ commentIndicatorHelper.addCommentIndicator(containerEl, coordinate);
+ buttonEl = containerEl.querySelector('button');
+ });
+
+ it('should append button to container', () => {
+ expect(buttonEl).toBeDefined();
+ });
+
+ describe('button', () => {
+ it('should set coordinate', () => {
+ expect(buttonEl.style.left).toEqual(`${coordinate.x}px`);
+ expect(buttonEl.style.top).toEqual(`${coordinate.y}px`);
+ });
+
+ it('should contain image-comment-dark svg', () => {
+ const svgEl = buttonEl.querySelector('svg');
+
+ expect(svgEl).toBeDefined();
+
+ const svgLink = svgEl.querySelector('use').getAttribute('xlink:href');
+
+ expect(svgLink.indexOf('image-comment-dark')).not.toBe(-1);
+ });
+ });
+ });
+
+ describe('removeCommentIndicator', () => {
+ it('should return removed false if there is no comment-indicator', () => {
+ const result = commentIndicatorHelper.removeCommentIndicator(containerEl);
+
+ expect(result.removed).toEqual(false);
+ });
+
+ describe('has comment indicator', () => {
+ let result;
+
+ beforeEach(() => {
+ containerEl.innerHTML = `
+ <div class="comment-indicator" style="left:${coordinate.x}px; top: ${coordinate.y}px;">
+ <img src="${gl.TEST_HOST}/image.png">
+ </div>
+ `;
+ result = commentIndicatorHelper.removeCommentIndicator(containerEl);
+ });
+
+ it('should remove comment indicator', () => {
+ expect(containerEl.querySelector('.comment-indicator')).toBeNull();
+ });
+
+ it('should return removed true', () => {
+ expect(result.removed).toEqual(true);
+ });
+
+ it('should return indicator meta', () => {
+ expect(result.x).toEqual(coordinate.x);
+ expect(result.y).toEqual(coordinate.y);
+ expect(result.image).toBeDefined();
+ expect(result.image.width).toBeDefined();
+ expect(result.image.height).toBeDefined();
+ });
+ });
+ });
+
+ describe('showCommentIndicator', () => {
+ describe('commentIndicator exists', () => {
+ beforeEach(() => {
+ containerEl.innerHTML = `
+ <button class="comment-indicator"></button>
+ `;
+ commentIndicatorHelper.showCommentIndicator(containerEl, coordinate);
+ });
+
+ it('should set commentIndicator coordinates', () => {
+ const commentIndicatorEl = containerEl.querySelector('.comment-indicator');
+
+ expect(commentIndicatorEl.style.left).toEqual(`${coordinate.x}px`);
+ expect(commentIndicatorEl.style.top).toEqual(`${coordinate.y}px`);
+ });
+ });
+
+ describe('commentIndicator does not exist', () => {
+ beforeEach(() => {
+ commentIndicatorHelper.showCommentIndicator(containerEl, coordinate);
+ });
+
+ it('should addCommentIndicator', () => {
+ const buttonEl = containerEl.querySelector('.comment-indicator');
+
+ expect(buttonEl).toBeDefined();
+ expect(buttonEl.style.left).toEqual(`${coordinate.x}px`);
+ expect(buttonEl.style.top).toEqual(`${coordinate.y}px`);
+ });
+ });
+ });
+
+ describe('commentIndicatorOnClick', () => {
+ let event;
+ let textAreaEl;
+
+ beforeEach(() => {
+ containerEl.innerHTML = `
+ <div class="diff-viewer">
+ <button></button>
+ <div class="note-container">
+ <textarea class="note-textarea"></textarea>
+ </div>
+ </div>
+ `;
+ textAreaEl = containerEl.querySelector('textarea');
+
+ event = {
+ stopPropagation: () => {},
+ currentTarget: containerEl.querySelector('button'),
+ };
+
+ jest.spyOn(event, 'stopPropagation').mockImplementation(() => {});
+ jest.spyOn(textAreaEl, 'focus').mockImplementation(() => {});
+ commentIndicatorHelper.commentIndicatorOnClick(event);
+ });
+
+ it('should stopPropagation', () => {
+ expect(event.stopPropagation).toHaveBeenCalled();
+ });
+
+ it('should focus textAreaEl', () => {
+ expect(textAreaEl.focus).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/image_diff/helpers/dom_helper_spec.js b/spec/frontend/image_diff/helpers/dom_helper_spec.js
new file mode 100644
index 00000000000..9357d626bbe
--- /dev/null
+++ b/spec/frontend/image_diff/helpers/dom_helper_spec.js
@@ -0,0 +1,120 @@
+import * as domHelper from '~/image_diff/helpers/dom_helper';
+import * as mockData from '../mock_data';
+
+describe('domHelper', () => {
+ const { imageMeta, badgeNumber } = mockData;
+
+ describe('setPositionDataAttribute', () => {
+ let containerEl;
+ let attributeAfterCall;
+ const position = {
+ myProperty: 'myProperty',
+ };
+
+ beforeEach(() => {
+ containerEl = document.createElement('div');
+ containerEl.dataset.position = JSON.stringify(position);
+ domHelper.setPositionDataAttribute(containerEl, imageMeta);
+ attributeAfterCall = JSON.parse(containerEl.dataset.position);
+ });
+
+ it('should set x, y, width, height', () => {
+ expect(attributeAfterCall.x).toEqual(imageMeta.x);
+ expect(attributeAfterCall.y).toEqual(imageMeta.y);
+ expect(attributeAfterCall.width).toEqual(imageMeta.width);
+ expect(attributeAfterCall.height).toEqual(imageMeta.height);
+ });
+
+ it('should not override other properties', () => {
+ expect(attributeAfterCall.myProperty).toEqual('myProperty');
+ });
+ });
+
+ describe('updateDiscussionAvatarBadgeNumber', () => {
+ let discussionEl;
+
+ beforeEach(() => {
+ discussionEl = document.createElement('div');
+ discussionEl.innerHTML = `
+ <a href="#" class="image-diff-avatar-link">
+ <div class="badge"></div>
+ </a>
+ `;
+ domHelper.updateDiscussionAvatarBadgeNumber(discussionEl, badgeNumber);
+ });
+
+ it('should update avatar badge number', () => {
+ expect(discussionEl.querySelector('.badge').textContent).toEqual(badgeNumber.toString());
+ });
+ });
+
+ describe('updateDiscussionBadgeNumber', () => {
+ let discussionEl;
+
+ beforeEach(() => {
+ discussionEl = document.createElement('div');
+ discussionEl.innerHTML = `
+ <div class="badge"></div>
+ `;
+ domHelper.updateDiscussionBadgeNumber(discussionEl, badgeNumber);
+ });
+
+ it('should update discussion badge number', () => {
+ expect(discussionEl.querySelector('.badge').textContent).toEqual(badgeNumber.toString());
+ });
+ });
+
+ describe('toggleCollapsed', () => {
+ let element;
+ let discussionNotesEl;
+
+ beforeEach(() => {
+ element = document.createElement('div');
+ element.innerHTML = `
+ <div class="discussion-notes">
+ <button></button>
+ <form class="discussion-form"></form>
+ </div>
+ `;
+ discussionNotesEl = element.querySelector('.discussion-notes');
+ });
+
+ describe('not collapsed', () => {
+ beforeEach(() => {
+ domHelper.toggleCollapsed({
+ currentTarget: element.querySelector('button'),
+ });
+ });
+
+ it('should add collapsed class', () => {
+ expect(discussionNotesEl.classList.contains('collapsed')).toEqual(true);
+ });
+
+ it('should force formEl to display none', () => {
+ const formEl = element.querySelector('.discussion-form');
+
+ expect(formEl.style.display).toEqual('none');
+ });
+ });
+
+ describe('collapsed', () => {
+ beforeEach(() => {
+ discussionNotesEl.classList.add('collapsed');
+
+ domHelper.toggleCollapsed({
+ currentTarget: element.querySelector('button'),
+ });
+ });
+
+ it('should remove collapsed class', () => {
+ expect(discussionNotesEl.classList.contains('collapsed')).toEqual(false);
+ });
+
+ it('should force formEl to display block', () => {
+ const formEl = element.querySelector('.discussion-form');
+
+ expect(formEl.style.display).toEqual('block');
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/image_diff/helpers/utils_helper_spec.js b/spec/frontend/image_diff/helpers/utils_helper_spec.js
index 3b6378be883..3b6378be883 100644
--- a/spec/javascripts/image_diff/helpers/utils_helper_spec.js
+++ b/spec/frontend/image_diff/helpers/utils_helper_spec.js
diff --git a/spec/frontend/image_diff/image_badge_spec.js b/spec/frontend/image_diff/image_badge_spec.js
new file mode 100644
index 00000000000..a11b50ead47
--- /dev/null
+++ b/spec/frontend/image_diff/image_badge_spec.js
@@ -0,0 +1,84 @@
+import ImageBadge from '~/image_diff/image_badge';
+import imageDiffHelper from '~/image_diff/helpers/index';
+import * as mockData from './mock_data';
+
+describe('ImageBadge', () => {
+ const { noteId, discussionId, imageMeta } = mockData;
+ const options = {
+ noteId,
+ discussionId,
+ };
+
+ it('should save actual property', () => {
+ const imageBadge = new ImageBadge({ ...options, actual: imageMeta });
+
+ const { actual } = imageBadge;
+
+ expect(actual.x).toEqual(imageMeta.x);
+ expect(actual.y).toEqual(imageMeta.y);
+ expect(actual.width).toEqual(imageMeta.width);
+ expect(actual.height).toEqual(imageMeta.height);
+ });
+
+ it('should save browser property', () => {
+ const imageBadge = new ImageBadge({ ...options, browser: imageMeta });
+
+ const { browser } = imageBadge;
+
+ expect(browser.x).toEqual(imageMeta.x);
+ expect(browser.y).toEqual(imageMeta.y);
+ expect(browser.width).toEqual(imageMeta.width);
+ expect(browser.height).toEqual(imageMeta.height);
+ });
+
+ it('should save noteId', () => {
+ const imageBadge = new ImageBadge(options);
+
+ expect(imageBadge.noteId).toEqual(noteId);
+ });
+
+ it('should save discussionId', () => {
+ const imageBadge = new ImageBadge(options);
+
+ expect(imageBadge.discussionId).toEqual(discussionId);
+ });
+
+ describe('default values', () => {
+ let imageBadge;
+
+ beforeEach(() => {
+ imageBadge = new ImageBadge(options);
+ });
+
+ it('should return defaultimageMeta if actual property is not provided', () => {
+ const { actual } = imageBadge;
+
+ expect(actual.x).toEqual(0);
+ expect(actual.y).toEqual(0);
+ expect(actual.width).toEqual(0);
+ expect(actual.height).toEqual(0);
+ });
+
+ it('should return defaultimageMeta if browser property is not provided', () => {
+ const { browser } = imageBadge;
+
+ expect(browser.x).toEqual(0);
+ expect(browser.y).toEqual(0);
+ expect(browser.width).toEqual(0);
+ expect(browser.height).toEqual(0);
+ });
+ });
+
+ describe('imageEl property is provided and not browser property', () => {
+ beforeEach(() => {
+ jest.spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').mockReturnValue(true);
+ });
+
+ it('should generate browser property', () => {
+ const imageBadge = new ImageBadge({ ...options, imageEl: document.createElement('img') });
+
+ expect(imageDiffHelper.resizeCoordinatesToImageElement).toHaveBeenCalled();
+ expect(imageBadge.browser).toEqual(true);
+ });
+ });
+});
diff --git a/spec/frontend/image_diff/image_diff_spec.js b/spec/frontend/image_diff/image_diff_spec.js
new file mode 100644
index 00000000000..c15718b5106
--- /dev/null
+++ b/spec/frontend/image_diff/image_diff_spec.js
@@ -0,0 +1,361 @@
+import ImageDiff from '~/image_diff/image_diff';
+import * as imageUtility from '~/lib/utils/image_utility';
+import imageDiffHelper from '~/image_diff/helpers/index';
+import * as mockData from './mock_data';
+
+describe('ImageDiff', () => {
+ let element;
+ let imageDiff;
+
+ beforeEach(() => {
+ setFixtures(`
+ <div id="element">
+ <div class="diff-file">
+ <div class="js-image-frame">
+ <img src="${gl.TEST_HOST}/image.png">
+ <div class="comment-indicator"></div>
+ <div id="badge-1" class="badge">1</div>
+ <div id="badge-2" class="badge">2</div>
+ <div id="badge-3" class="badge">3</div>
+ </div>
+ <div class="note-container">
+ <div class="discussion-notes">
+ <div class="js-diff-notes-toggle"></div>
+ <div class="notes"></div>
+ </div>
+ <div class="discussion-notes">
+ <div class="js-diff-notes-toggle"></div>
+ <div class="notes"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ `);
+ element = document.getElementById('element');
+ });
+
+ describe('constructor', () => {
+ beforeEach(() => {
+ imageDiff = new ImageDiff(element, {
+ canCreateNote: true,
+ renderCommentBadge: true,
+ });
+ });
+
+ it('should set el', () => {
+ expect(imageDiff.el).toEqual(element);
+ });
+
+ it('should set canCreateNote', () => {
+ expect(imageDiff.canCreateNote).toEqual(true);
+ });
+
+ it('should set renderCommentBadge', () => {
+ expect(imageDiff.renderCommentBadge).toEqual(true);
+ });
+
+ it('should set $noteContainer', () => {
+ expect(imageDiff.$noteContainer[0]).toEqual(element.querySelector('.note-container'));
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ imageDiff = new ImageDiff(element);
+ });
+
+ it('should set canCreateNote as false', () => {
+ expect(imageDiff.canCreateNote).toEqual(false);
+ });
+
+ it('should set renderCommentBadge as false', () => {
+ expect(imageDiff.renderCommentBadge).toEqual(false);
+ });
+ });
+ });
+
+ describe('init', () => {
+ beforeEach(() => {
+ jest.spyOn(ImageDiff.prototype, 'bindEvents').mockImplementation(() => {});
+ imageDiff = new ImageDiff(element);
+ imageDiff.init();
+ });
+
+ it('should set imageFrameEl', () => {
+ expect(imageDiff.imageFrameEl).toEqual(element.querySelector('.diff-file .js-image-frame'));
+ });
+
+ it('should set imageEl', () => {
+ expect(imageDiff.imageEl).toEqual(element.querySelector('.diff-file .js-image-frame img'));
+ });
+
+ it('should call bindEvents', () => {
+ expect(imageDiff.bindEvents).toHaveBeenCalled();
+ });
+ });
+
+ describe('bindEvents', () => {
+ let imageEl;
+
+ beforeEach(() => {
+ jest.spyOn(imageDiffHelper, 'toggleCollapsed').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'commentIndicatorOnClick').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'removeCommentIndicator').mockImplementation(() => {});
+ jest.spyOn(ImageDiff.prototype, 'imageClicked').mockImplementation(() => {});
+ jest.spyOn(ImageDiff.prototype, 'addBadge').mockImplementation(() => {});
+ jest.spyOn(ImageDiff.prototype, 'removeBadge').mockImplementation(() => {});
+ jest.spyOn(ImageDiff.prototype, 'renderBadges').mockImplementation(() => {});
+ imageEl = element.querySelector('.diff-file .js-image-frame img');
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false);
+ imageDiff = new ImageDiff(element);
+ imageDiff.imageEl = imageEl;
+ imageDiff.bindEvents();
+ });
+
+ it('should register click event delegation to js-diff-notes-toggle', () => {
+ element.querySelector('.js-diff-notes-toggle').click();
+
+ expect(imageDiffHelper.toggleCollapsed).toHaveBeenCalled();
+ });
+
+ it('should register click event delegation to comment-indicator', () => {
+ element.querySelector('.comment-indicator').click();
+
+ expect(imageDiffHelper.commentIndicatorOnClick).toHaveBeenCalled();
+ });
+ });
+
+ describe('image not loaded', () => {
+ beforeEach(() => {
+ jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false);
+ imageDiff = new ImageDiff(element);
+ imageDiff.imageEl = imageEl;
+ imageDiff.bindEvents();
+ });
+
+ it('should registers load eventListener', () => {
+ const loadEvent = new Event('load');
+ imageEl.dispatchEvent(loadEvent);
+
+ expect(imageDiff.renderBadges).toHaveBeenCalled();
+ });
+ });
+
+ describe('canCreateNote', () => {
+ beforeEach(() => {
+ jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false);
+ imageDiff = new ImageDiff(element, {
+ canCreateNote: true,
+ });
+ imageDiff.imageEl = imageEl;
+ imageDiff.bindEvents();
+ });
+
+ it('should register click.imageDiff event', () => {
+ const event = new CustomEvent('click.imageDiff');
+ element.dispatchEvent(event);
+
+ expect(imageDiff.imageClicked).toHaveBeenCalled();
+ });
+
+ it('should register blur.imageDiff event', () => {
+ const event = new CustomEvent('blur.imageDiff');
+ element.dispatchEvent(event);
+
+ expect(imageDiffHelper.removeCommentIndicator).toHaveBeenCalled();
+ });
+
+ it('should register addBadge.imageDiff event', () => {
+ const event = new CustomEvent('addBadge.imageDiff');
+ element.dispatchEvent(event);
+
+ expect(imageDiff.addBadge).toHaveBeenCalled();
+ });
+
+ it('should register removeBadge.imageDiff event', () => {
+ const event = new CustomEvent('removeBadge.imageDiff');
+ element.dispatchEvent(event);
+
+ expect(imageDiff.removeBadge).toHaveBeenCalled();
+ });
+ });
+
+ describe('canCreateNote is false', () => {
+ beforeEach(() => {
+ jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false);
+ imageDiff = new ImageDiff(element);
+ imageDiff.imageEl = imageEl;
+ imageDiff.bindEvents();
+ });
+
+ it('should not register click.imageDiff event', () => {
+ const event = new CustomEvent('click.imageDiff');
+ element.dispatchEvent(event);
+
+ expect(imageDiff.imageClicked).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('imageClicked', () => {
+ beforeEach(() => {
+ jest.spyOn(imageDiffHelper, 'getTargetSelection').mockReturnValue({
+ actual: {},
+ browser: {},
+ });
+ jest.spyOn(imageDiffHelper, 'setPositionDataAttribute').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'showCommentIndicator').mockImplementation(() => {});
+ imageDiff = new ImageDiff(element);
+ imageDiff.imageClicked({
+ detail: {
+ currentTarget: {},
+ },
+ });
+ });
+
+ it('should call getTargetSelection', () => {
+ expect(imageDiffHelper.getTargetSelection).toHaveBeenCalled();
+ });
+
+ it('should call setPositionDataAttribute', () => {
+ expect(imageDiffHelper.setPositionDataAttribute).toHaveBeenCalled();
+ });
+
+ it('should call showCommentIndicator', () => {
+ expect(imageDiffHelper.showCommentIndicator).toHaveBeenCalled();
+ });
+ });
+
+ describe('renderBadges', () => {
+ beforeEach(() => {
+ jest.spyOn(ImageDiff.prototype, 'renderBadge').mockImplementation(() => {});
+ imageDiff = new ImageDiff(element);
+ imageDiff.renderBadges();
+ });
+
+ it('should call renderBadge for each discussionEl', () => {
+ const discussionEls = element.querySelectorAll('.note-container .discussion-notes .notes');
+
+ expect(imageDiff.renderBadge.mock.calls.length).toEqual(discussionEls.length);
+ });
+ });
+
+ describe('renderBadge', () => {
+ let discussionEls;
+
+ beforeEach(() => {
+ jest.spyOn(imageDiffHelper, 'addImageBadge').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'addImageCommentBadge').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'generateBadgeFromDiscussionDOM').mockReturnValue({
+ browser: {},
+ noteId: 'noteId',
+ });
+ discussionEls = element.querySelectorAll('.note-container .discussion-notes .notes');
+ imageDiff = new ImageDiff(element);
+ imageDiff.renderBadge(discussionEls[0], 0);
+ });
+
+ it('should populate imageBadges', () => {
+ expect(imageDiff.imageBadges.length).toEqual(1);
+ });
+
+ describe('renderCommentBadge', () => {
+ beforeEach(() => {
+ imageDiff.renderCommentBadge = true;
+ imageDiff.renderBadge(discussionEls[0], 0);
+ });
+
+ it('should call addImageCommentBadge', () => {
+ expect(imageDiffHelper.addImageCommentBadge).toHaveBeenCalled();
+ });
+ });
+
+ describe('renderCommentBadge is false', () => {
+ it('should call addImageBadge', () => {
+ expect(imageDiffHelper.addImageBadge).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('addBadge', () => {
+ beforeEach(() => {
+ jest.spyOn(imageDiffHelper, 'addImageBadge').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'addAvatarBadge').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'updateDiscussionBadgeNumber').mockImplementation(() => {});
+ imageDiff = new ImageDiff(element);
+ imageDiff.imageFrameEl = element.querySelector('.diff-file .js-image-frame');
+ imageDiff.addBadge({
+ detail: {
+ x: 0,
+ y: 1,
+ width: 25,
+ height: 50,
+ noteId: 'noteId',
+ discussionId: 'discussionId',
+ },
+ });
+ });
+
+ it('should add imageBadge to imageBadges', () => {
+ expect(imageDiff.imageBadges.length).toEqual(1);
+ });
+
+ it('should call addImageBadge', () => {
+ expect(imageDiffHelper.addImageBadge).toHaveBeenCalled();
+ });
+
+ it('should call addAvatarBadge', () => {
+ expect(imageDiffHelper.addAvatarBadge).toHaveBeenCalled();
+ });
+
+ it('should call updateDiscussionBadgeNumber', () => {
+ expect(imageDiffHelper.updateDiscussionBadgeNumber).toHaveBeenCalled();
+ });
+ });
+
+ describe('removeBadge', () => {
+ beforeEach(() => {
+ const { imageMeta } = mockData;
+
+ jest.spyOn(imageDiffHelper, 'updateDiscussionBadgeNumber').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'updateDiscussionAvatarBadgeNumber').mockImplementation(() => {});
+ imageDiff = new ImageDiff(element);
+ imageDiff.imageBadges = [imageMeta, imageMeta, imageMeta];
+ imageDiff.imageFrameEl = element.querySelector('.diff-file .js-image-frame');
+ imageDiff.removeBadge({
+ detail: {
+ badgeNumber: 2,
+ },
+ });
+ });
+
+ describe('cascade badge count', () => {
+ it('should update next imageBadgeEl value', () => {
+ const imageBadgeEls = imageDiff.imageFrameEl.querySelectorAll('.badge');
+
+ expect(imageBadgeEls[0].textContent).toEqual('1');
+ expect(imageBadgeEls[1].textContent).toEqual('2');
+ expect(imageBadgeEls.length).toEqual(2);
+ });
+
+ it('should call updateDiscussionBadgeNumber', () => {
+ expect(imageDiffHelper.updateDiscussionBadgeNumber).toHaveBeenCalled();
+ });
+
+ it('should call updateDiscussionAvatarBadgeNumber', () => {
+ expect(imageDiffHelper.updateDiscussionAvatarBadgeNumber).toHaveBeenCalled();
+ });
+ });
+
+ it('should remove badge from imageBadges', () => {
+ expect(imageDiff.imageBadges.length).toEqual(2);
+ });
+
+ it('should remove imageBadgeEl', () => {
+ expect(imageDiff.imageFrameEl.querySelector('#badge-2')).toBeNull();
+ });
+ });
+});
diff --git a/spec/javascripts/image_diff/mock_data.js b/spec/frontend/image_diff/mock_data.js
index a0d1732dd0a..a0d1732dd0a 100644
--- a/spec/javascripts/image_diff/mock_data.js
+++ b/spec/frontend/image_diff/mock_data.js
diff --git a/spec/frontend/image_diff/replaced_image_diff_spec.js b/spec/frontend/image_diff/replaced_image_diff_spec.js
new file mode 100644
index 00000000000..f2a7b7f8406
--- /dev/null
+++ b/spec/frontend/image_diff/replaced_image_diff_spec.js
@@ -0,0 +1,356 @@
+import ReplacedImageDiff from '~/image_diff/replaced_image_diff';
+import ImageDiff from '~/image_diff/image_diff';
+import { viewTypes } from '~/image_diff/view_types';
+import imageDiffHelper from '~/image_diff/helpers/index';
+
+describe('ReplacedImageDiff', () => {
+ let element;
+ let replacedImageDiff;
+
+ beforeEach(() => {
+ setFixtures(`
+ <div id="element">
+ <div class="two-up">
+ <div class="js-image-frame">
+ <img src="${gl.TEST_HOST}/image.png">
+ </div>
+ </div>
+ <div class="swipe">
+ <div class="js-image-frame">
+ <img src="${gl.TEST_HOST}/image.png">
+ </div>
+ </div>
+ <div class="onion-skin">
+ <div class="js-image-frame">
+ <img src="${gl.TEST_HOST}/image.png">
+ </div>
+ </div>
+ <div class="view-modes-menu">
+ <div class="two-up">2-up</div>
+ <div class="swipe">Swipe</div>
+ <div class="onion-skin">Onion skin</div>
+ </div>
+ </div>
+ `);
+ element = document.getElementById('element');
+ });
+
+ function setupImageFrameEls() {
+ replacedImageDiff.imageFrameEls = [];
+ replacedImageDiff.imageFrameEls[viewTypes.TWO_UP] = element.querySelector(
+ '.two-up .js-image-frame',
+ );
+ replacedImageDiff.imageFrameEls[viewTypes.SWIPE] = element.querySelector(
+ '.swipe .js-image-frame',
+ );
+ replacedImageDiff.imageFrameEls[viewTypes.ONION_SKIN] = element.querySelector(
+ '.onion-skin .js-image-frame',
+ );
+ }
+
+ function setupViewModesEls() {
+ replacedImageDiff.viewModesEls = [];
+ replacedImageDiff.viewModesEls[viewTypes.TWO_UP] = element.querySelector(
+ '.view-modes-menu .two-up',
+ );
+ replacedImageDiff.viewModesEls[viewTypes.SWIPE] = element.querySelector(
+ '.view-modes-menu .swipe',
+ );
+ replacedImageDiff.viewModesEls[viewTypes.ONION_SKIN] = element.querySelector(
+ '.view-modes-menu .onion-skin',
+ );
+ }
+
+ function setupImageEls() {
+ replacedImageDiff.imageEls = [];
+ replacedImageDiff.imageEls[viewTypes.TWO_UP] = element.querySelector('.two-up img');
+ replacedImageDiff.imageEls[viewTypes.SWIPE] = element.querySelector('.swipe img');
+ replacedImageDiff.imageEls[viewTypes.ONION_SKIN] = element.querySelector('.onion-skin img');
+ }
+
+ it('should extend ImageDiff', () => {
+ replacedImageDiff = new ReplacedImageDiff(element);
+
+ expect(replacedImageDiff instanceof ImageDiff).toEqual(true);
+ });
+
+ describe('init', () => {
+ beforeEach(() => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'bindEvents').mockImplementation(() => {});
+ jest.spyOn(ReplacedImageDiff.prototype, 'generateImageEls').mockImplementation(() => {});
+
+ replacedImageDiff = new ReplacedImageDiff(element);
+ replacedImageDiff.init();
+ });
+
+ it('should set imageFrameEls', () => {
+ const { imageFrameEls } = replacedImageDiff;
+
+ expect(imageFrameEls).toBeDefined();
+ expect(imageFrameEls[viewTypes.TWO_UP]).toEqual(
+ element.querySelector('.two-up .js-image-frame'),
+ );
+
+ expect(imageFrameEls[viewTypes.SWIPE]).toEqual(
+ element.querySelector('.swipe .js-image-frame'),
+ );
+
+ expect(imageFrameEls[viewTypes.ONION_SKIN]).toEqual(
+ element.querySelector('.onion-skin .js-image-frame'),
+ );
+ });
+
+ it('should set viewModesEls', () => {
+ const { viewModesEls } = replacedImageDiff;
+
+ expect(viewModesEls).toBeDefined();
+ expect(viewModesEls[viewTypes.TWO_UP]).toEqual(
+ element.querySelector('.view-modes-menu .two-up'),
+ );
+
+ expect(viewModesEls[viewTypes.SWIPE]).toEqual(
+ element.querySelector('.view-modes-menu .swipe'),
+ );
+
+ expect(viewModesEls[viewTypes.ONION_SKIN]).toEqual(
+ element.querySelector('.view-modes-menu .onion-skin'),
+ );
+ });
+
+ it('should generateImageEls', () => {
+ expect(ReplacedImageDiff.prototype.generateImageEls).toHaveBeenCalled();
+ });
+
+ it('should bindEvents', () => {
+ expect(ReplacedImageDiff.prototype.bindEvents).toHaveBeenCalled();
+ });
+
+ describe('currentView', () => {
+ it('should set currentView', () => {
+ replacedImageDiff.init(viewTypes.ONION_SKIN);
+
+ expect(replacedImageDiff.currentView).toEqual(viewTypes.ONION_SKIN);
+ });
+
+ it('should default to viewTypes.TWO_UP', () => {
+ expect(replacedImageDiff.currentView).toEqual(viewTypes.TWO_UP);
+ });
+ });
+ });
+
+ describe('generateImageEls', () => {
+ beforeEach(() => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'bindEvents').mockImplementation(() => {});
+
+ replacedImageDiff = new ReplacedImageDiff(element, {
+ canCreateNote: false,
+ renderCommentBadge: false,
+ });
+
+ setupImageFrameEls();
+ });
+
+ it('should set imageEls', () => {
+ replacedImageDiff.generateImageEls();
+ const { imageEls } = replacedImageDiff;
+
+ expect(imageEls).toBeDefined();
+ expect(imageEls[viewTypes.TWO_UP]).toEqual(element.querySelector('.two-up img'));
+ expect(imageEls[viewTypes.SWIPE]).toEqual(element.querySelector('.swipe img'));
+ expect(imageEls[viewTypes.ONION_SKIN]).toEqual(element.querySelector('.onion-skin img'));
+ });
+ });
+
+ describe('bindEvents', () => {
+ beforeEach(() => {
+ jest.spyOn(ImageDiff.prototype, 'bindEvents').mockImplementation(() => {});
+ replacedImageDiff = new ReplacedImageDiff(element);
+
+ setupViewModesEls();
+ });
+
+ it('should call super.bindEvents', () => {
+ replacedImageDiff.bindEvents();
+
+ expect(ImageDiff.prototype.bindEvents).toHaveBeenCalled();
+ });
+
+ it('should register click eventlistener to 2-up view mode', done => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation(viewMode => {
+ expect(viewMode).toEqual(viewTypes.TWO_UP);
+ done();
+ });
+
+ replacedImageDiff.bindEvents();
+ replacedImageDiff.viewModesEls[viewTypes.TWO_UP].click();
+ });
+
+ it('should register click eventlistener to swipe view mode', done => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation(viewMode => {
+ expect(viewMode).toEqual(viewTypes.SWIPE);
+ done();
+ });
+
+ replacedImageDiff.bindEvents();
+ replacedImageDiff.viewModesEls[viewTypes.SWIPE].click();
+ });
+
+ it('should register click eventlistener to onion skin view mode', done => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation(viewMode => {
+ expect(viewMode).toEqual(viewTypes.SWIPE);
+ done();
+ });
+
+ replacedImageDiff.bindEvents();
+ replacedImageDiff.viewModesEls[viewTypes.SWIPE].click();
+ });
+ });
+
+ describe('getters', () => {
+ describe('imageEl', () => {
+ beforeEach(() => {
+ replacedImageDiff = new ReplacedImageDiff(element);
+ replacedImageDiff.currentView = viewTypes.TWO_UP;
+ setupImageEls();
+ });
+
+ it('should return imageEl based on currentView', () => {
+ expect(replacedImageDiff.imageEl).toEqual(element.querySelector('.two-up img'));
+
+ replacedImageDiff.currentView = viewTypes.SWIPE;
+
+ expect(replacedImageDiff.imageEl).toEqual(element.querySelector('.swipe img'));
+ });
+ });
+
+ describe('imageFrameEl', () => {
+ beforeEach(() => {
+ replacedImageDiff = new ReplacedImageDiff(element);
+ replacedImageDiff.currentView = viewTypes.TWO_UP;
+ setupImageFrameEls();
+ });
+
+ it('should return imageFrameEl based on currentView', () => {
+ expect(replacedImageDiff.imageFrameEl).toEqual(
+ element.querySelector('.two-up .js-image-frame'),
+ );
+
+ replacedImageDiff.currentView = viewTypes.ONION_SKIN;
+
+ expect(replacedImageDiff.imageFrameEl).toEqual(
+ element.querySelector('.onion-skin .js-image-frame'),
+ );
+ });
+ });
+ });
+
+ describe('changeView', () => {
+ beforeEach(() => {
+ replacedImageDiff = new ReplacedImageDiff(element);
+ jest.spyOn(imageDiffHelper, 'removeCommentIndicator').mockReturnValue({
+ removed: false,
+ });
+ setupImageFrameEls();
+ });
+
+ describe('invalid viewType', () => {
+ beforeEach(() => {
+ replacedImageDiff.changeView('some-view-name');
+ });
+
+ it('should not call removeCommentIndicator', () => {
+ expect(imageDiffHelper.removeCommentIndicator).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('valid viewType', () => {
+ beforeEach(() => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'renderNewView').mockImplementation(() => {});
+ replacedImageDiff.changeView(viewTypes.ONION_SKIN);
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ });
+
+ it('should call removeCommentIndicator', () => {
+ expect(imageDiffHelper.removeCommentIndicator).toHaveBeenCalled();
+ });
+
+ it('should update currentView to newView', () => {
+ expect(replacedImageDiff.currentView).toEqual(viewTypes.ONION_SKIN);
+ });
+
+ it('should clear imageBadges', () => {
+ expect(replacedImageDiff.imageBadges.length).toEqual(0);
+ });
+
+ it('should call renderNewView', () => {
+ jest.advanceTimersByTime(251);
+
+ expect(replacedImageDiff.renderNewView).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('renderNewView', () => {
+ beforeEach(() => {
+ replacedImageDiff = new ReplacedImageDiff(element);
+ });
+
+ it('should call renderBadges', () => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'renderBadges').mockImplementation(() => {});
+
+ replacedImageDiff.renderNewView({
+ removed: false,
+ });
+
+ expect(replacedImageDiff.renderBadges).toHaveBeenCalled();
+ });
+
+ describe('removeIndicator', () => {
+ const indicator = {
+ removed: true,
+ x: 0,
+ y: 1,
+ image: {
+ width: 50,
+ height: 100,
+ },
+ };
+
+ beforeEach(() => {
+ setupImageEls();
+ setupImageFrameEls();
+ });
+
+ it('should pass showCommentIndicator normalized indicator values', done => {
+ jest.spyOn(imageDiffHelper, 'showCommentIndicator').mockImplementation(() => {});
+ jest
+ .spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement')
+ .mockImplementation((imageEl, meta) => {
+ expect(meta.x).toEqual(indicator.x);
+ expect(meta.y).toEqual(indicator.y);
+ expect(meta.width).toEqual(indicator.image.width);
+ expect(meta.height).toEqual(indicator.image.height);
+ done();
+ });
+ replacedImageDiff.renderNewView(indicator);
+ });
+
+ it('should call showCommentIndicator', done => {
+ const normalized = {
+ normalized: true,
+ };
+ jest.spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').mockReturnValue(normalized);
+ jest
+ .spyOn(imageDiffHelper, 'showCommentIndicator')
+ .mockImplementation((imageFrameEl, normalizedIndicator) => {
+ expect(normalizedIndicator).toEqual(normalized);
+ done();
+ });
+ replacedImageDiff.renderNewView(indicator);
+ });
+ });
+ });
+});
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 8f60823ee72..9491b52c888 100644
--- a/spec/frontend/import_projects/components/import_projects_table_spec.js
+++ b/spec/frontend/import_projects/components/import_projects_table_spec.js
@@ -17,11 +17,12 @@ describe('ImportProjectsTable', () => {
};
function initStore() {
- const stubbedActions = Object.assign({}, actions, {
+ const stubbedActions = {
+ ...actions,
fetchJobs: jest.fn(),
fetchRepos: jest.fn(actions.requestRepos),
fetchImport: jest.fn(actions.requestImport),
- });
+ };
const store = new Vuex.Store({
state: state(),
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 8efd526e360..8be645c496f 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
@@ -18,9 +18,7 @@ describe('ProviderRepoTableRow', () => {
};
function initStore() {
- const stubbedActions = Object.assign({}, actions, {
- fetchImport,
- });
+ const stubbedActions = { ...actions, fetchImport };
const store = new Vuex.Store({
state: state(),
diff --git a/spec/frontend/integrations/edit/components/active_toggle_spec.js b/spec/frontend/integrations/edit/components/active_toggle_spec.js
index 8a11c200c15..5469b45f708 100644
--- a/spec/frontend/integrations/edit/components/active_toggle_spec.js
+++ b/spec/frontend/integrations/edit/components/active_toggle_spec.js
@@ -9,17 +9,19 @@ describe('ActiveToggle', () => {
const defaultProps = {
initialActivated: true,
- disabled: false,
};
const createComponent = props => {
wrapper = mount(ActiveToggle, {
- propsData: Object.assign({}, defaultProps, props),
+ propsData: { ...defaultProps, ...props },
});
};
afterEach(() => {
- if (wrapper) wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
const findGlToggle = () => wrapper.find(GlToggle);
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
new file mode 100644
index 00000000000..c93f63b11d0
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -0,0 +1,99 @@
+import { shallowMount } from '@vue/test-utils';
+import IntegrationForm from '~/integrations/edit/components/integration_form.vue';
+import ActiveToggle from '~/integrations/edit/components/active_toggle.vue';
+import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
+import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
+
+describe('IntegrationForm', () => {
+ let wrapper;
+
+ const defaultProps = {
+ activeToggleProps: {
+ initialActivated: true,
+ },
+ showActive: true,
+ triggerFieldsProps: {
+ initialTriggerCommit: false,
+ initialTriggerMergeRequest: false,
+ initialEnableComments: false,
+ },
+ type: '',
+ };
+
+ const createComponent = props => {
+ wrapper = shallowMount(IntegrationForm, {
+ propsData: { ...defaultProps, ...props },
+ stubs: {
+ ActiveToggle,
+ JiraTriggerFields,
+ },
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findActiveToggle = () => wrapper.find(ActiveToggle);
+ const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields);
+ const findTriggerFields = () => wrapper.find(TriggerFields);
+
+ describe('template', () => {
+ describe('showActive is true', () => {
+ it('renders ActiveToggle', () => {
+ createComponent();
+
+ expect(findActiveToggle().exists()).toBe(true);
+ });
+ });
+
+ describe('showActive is false', () => {
+ it('does not render ActiveToggle', () => {
+ createComponent({
+ showActive: false,
+ });
+
+ expect(findActiveToggle().exists()).toBe(false);
+ });
+ });
+
+ describe('type is "slack"', () => {
+ it('does not render JiraTriggerFields', () => {
+ createComponent({
+ type: 'slack',
+ });
+
+ expect(findJiraTriggerFields().exists()).toBe(false);
+ });
+ });
+
+ describe('type is "jira"', () => {
+ it('renders JiraTriggerFields', () => {
+ createComponent({
+ type: 'jira',
+ });
+
+ expect(findJiraTriggerFields().exists()).toBe(true);
+ });
+ });
+
+ describe('triggerEvents is present', () => {
+ it('renders TriggerFields', () => {
+ const events = [{ title: 'push' }];
+ const type = 'slack';
+
+ createComponent({
+ triggerEvents: events,
+ type,
+ });
+
+ expect(findTriggerFields().exists()).toBe(true);
+ expect(findTriggerFields().props('events')).toBe(events);
+ expect(findTriggerFields().props('type')).toBe(type);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js
new file mode 100644
index 00000000000..e4c2a0be6a3
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js
@@ -0,0 +1,97 @@
+import { mount } from '@vue/test-utils';
+import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
+import { GlFormCheckbox } from '@gitlab/ui';
+
+describe('JiraTriggerFields', () => {
+ let wrapper;
+
+ const defaultProps = {
+ initialTriggerCommit: false,
+ initialTriggerMergeRequest: false,
+ initialEnableComments: false,
+ };
+
+ const createComponent = props => {
+ wrapper = mount(JiraTriggerFields, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findCommentSettings = () => wrapper.find('[data-testid="comment-settings"]');
+ const findCommentDetail = () => wrapper.find('[data-testid="comment-detail"]');
+ const findCommentSettingsCheckbox = () => findCommentSettings().find(GlFormCheckbox);
+
+ describe('template', () => {
+ describe('initialTriggerCommit and initialTriggerMergeRequest are false', () => {
+ it('does not show comment settings', () => {
+ createComponent();
+
+ expect(findCommentSettings().isVisible()).toBe(false);
+ expect(findCommentDetail().isVisible()).toBe(false);
+ });
+ });
+
+ describe('initialTriggerCommit is true', () => {
+ beforeEach(() => {
+ createComponent({
+ initialTriggerCommit: true,
+ });
+ });
+
+ it('shows comment settings', () => {
+ expect(findCommentSettings().isVisible()).toBe(true);
+ expect(findCommentDetail().isVisible()).toBe(false);
+ });
+
+ // As per https://vuejs.org/v2/guide/forms.html#Checkbox-1,
+ // browsers don't include unchecked boxes in form submissions.
+ it('includes comment settings as false even if unchecked', () => {
+ expect(
+ findCommentSettings()
+ .find('input[name="service[comment_on_event_enabled]"]')
+ .exists(),
+ ).toBe(true);
+ });
+
+ describe('on enable comments', () => {
+ it('shows comment detail', () => {
+ findCommentSettingsCheckbox().vm.$emit('input', true);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findCommentDetail().isVisible()).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('initialTriggerMergeRequest is true', () => {
+ it('shows comment settings', () => {
+ createComponent({
+ initialTriggerMergeRequest: true,
+ });
+
+ expect(findCommentSettings().isVisible()).toBe(true);
+ expect(findCommentDetail().isVisible()).toBe(false);
+ });
+ });
+
+ describe('initialTriggerCommit is true, initialEnableComments is true', () => {
+ it('shows comment settings and comment detail', () => {
+ createComponent({
+ initialTriggerCommit: true,
+ initialEnableComments: true,
+ });
+
+ expect(findCommentSettings().isVisible()).toBe(true);
+ expect(findCommentDetail().isVisible()).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/trigger_fields_spec.js b/spec/frontend/integrations/edit/components/trigger_fields_spec.js
new file mode 100644
index 00000000000..337876c6d16
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/trigger_fields_spec.js
@@ -0,0 +1,136 @@
+import { mount } from '@vue/test-utils';
+import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
+import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
+
+describe('TriggerFields', () => {
+ let wrapper;
+
+ const defaultProps = {
+ type: 'slack',
+ };
+
+ const createComponent = props => {
+ wrapper = mount(TriggerFields, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findAllGlFormCheckboxes = () => wrapper.findAll(GlFormCheckbox);
+ const findAllGlFormInputs = () => wrapper.findAll(GlFormInput);
+
+ describe('template', () => {
+ it('renders a label with text "Trigger"', () => {
+ createComponent();
+
+ const triggerLabel = wrapper.find('[data-testid="trigger-fields-group"]').find('label');
+ expect(triggerLabel.exists()).toBe(true);
+ expect(triggerLabel.text()).toBe('Trigger');
+ });
+
+ describe('events without field property', () => {
+ const events = [
+ {
+ title: 'push',
+ name: 'push_event',
+ description: 'Event on push',
+ value: true,
+ },
+ {
+ title: 'merge_request',
+ name: 'merge_requests_event',
+ description: 'Event on merge_request',
+ value: false,
+ },
+ ];
+
+ beforeEach(() => {
+ createComponent({
+ events,
+ });
+ });
+
+ it('does not render GlFormInput for each event', () => {
+ expect(findAllGlFormInputs().exists()).toBe(false);
+ });
+
+ it('renders GlFormInput with description for each event', () => {
+ const groups = wrapper.find('#trigger-fields').findAll(GlFormGroup);
+
+ expect(groups).toHaveLength(2);
+ groups.wrappers.forEach((group, index) => {
+ expect(group.find('small').text()).toBe(events[index].description);
+ });
+ });
+
+ it('renders GlFormCheckbox for each event', () => {
+ const checkboxes = findAllGlFormCheckboxes();
+ const expectedResults = [
+ { labelText: 'Push', inputName: 'service[push_event]' },
+ { labelText: 'Merge Request', inputName: 'service[merge_requests_event]' },
+ ];
+ expect(checkboxes).toHaveLength(2);
+
+ checkboxes.wrappers.forEach((checkbox, index) => {
+ expect(checkbox.find('label').text()).toBe(expectedResults[index].labelText);
+ expect(checkbox.find('input').attributes('name')).toBe(expectedResults[index].inputName);
+ expect(checkbox.vm.$attrs.checked).toBe(events[index].value);
+ });
+ });
+ });
+
+ describe('events with field property', () => {
+ const events = [
+ {
+ field: {
+ name: 'push_channel',
+ value: '',
+ },
+ },
+ {
+ field: {
+ name: 'merge_request_channel',
+ value: 'gitlab-development',
+ },
+ },
+ ];
+
+ beforeEach(() => {
+ createComponent({
+ events,
+ });
+ });
+
+ it('renders GlFormCheckbox for each event', () => {
+ expect(findAllGlFormCheckboxes()).toHaveLength(2);
+ });
+
+ it('renders GlFormInput for each event', () => {
+ const fields = findAllGlFormInputs();
+ const expectedResults = [
+ {
+ name: 'service[push_channel]',
+ placeholder: 'Slack channels (e.g. general, development)',
+ },
+ {
+ name: 'service[merge_request_channel]',
+ placeholder: 'Slack channels (e.g. general, development)',
+ },
+ ];
+
+ expect(fields).toHaveLength(2);
+
+ fields.wrappers.forEach((field, index) => {
+ expect(field.attributes()).toMatchObject(expectedResults[index]);
+ expect(field.vm.$attrs.value).toBe(events[index].field.value);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js
new file mode 100644
index 00000000000..c117a37ff2f
--- /dev/null
+++ b/spec/frontend/integrations/integration_settings_form_spec.js
@@ -0,0 +1,268 @@
+import $ from 'jquery';
+import MockAdaptor from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import IntegrationSettingsForm from '~/integrations/integration_settings_form';
+
+describe('IntegrationSettingsForm', () => {
+ const FIXTURE = 'services/edit_service.html';
+ preloadFixtures(FIXTURE);
+
+ beforeEach(() => {
+ loadFixtures(FIXTURE);
+ });
+
+ describe('contructor', () => {
+ let integrationSettingsForm;
+
+ beforeEach(() => {
+ integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ jest.spyOn(integrationSettingsForm, 'init').mockImplementation(() => {});
+ });
+
+ it('should initialize form element refs on class object', () => {
+ // Form Reference
+ 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();
+ });
+ });
+
+ describe('toggleServiceState', () => {
+ let integrationSettingsForm;
+
+ beforeEach(() => {
+ integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ });
+
+ it('should remove `novalidate` attribute to form when called with `true`', () => {
+ integrationSettingsForm.formActive = true;
+ integrationSettingsForm.toggleServiceState();
+
+ expect(integrationSettingsForm.$form.attr('novalidate')).not.toBeDefined();
+ });
+
+ it('should set `novalidate` attribute to form when called with `false`', () => {
+ integrationSettingsForm.formActive = false;
+ integrationSettingsForm.toggleServiceState();
+
+ expect(integrationSettingsForm.$form.attr('novalidate')).toBeDefined();
+ });
+ });
+
+ 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;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdaptor(axios);
+
+ jest.spyOn(axios, 'put');
+
+ integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ // eslint-disable-next-line no-jquery/no-serialize
+ formData = integrationSettingsForm.$form.serialize();
+ });
+
+ afterEach(() => {
+ 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,
+ });
+
+ 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');
+ });
+ });
+
+ 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', () => {
+ jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {});
+
+ mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
+ error: false,
+ });
+
+ return integrationSettingsForm.testSettings(formData).then(() => {
+ expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
+ });
+ });
+
+ it('should submit form when clicked on `Save anyway` action of error Flash', () => {
+ jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {});
+
+ const errorMessage = 'Test failed.';
+ mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
+ error: true,
+ message: errorMessage,
+ test_failed: true,
+ });
+
+ return integrationSettingsForm
+ .testSettings(formData)
+ .then(() => {
+ const $flashAction = $('.flash-container .flash-action');
+
+ expect($flashAction).toBeDefined();
+
+ $flashAction.get(0).click();
+ })
+ .then(() => {
+ expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
+ });
+ });
+
+ it('should show error Flash if ajax request failed', () => {
+ 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);
+ });
+ });
+
+ it('should always call `toggleSubmitBtnState` with `false` once request is completed', () => {
+ mock.onPut(integrationSettingsForm.testEndPoint).networkError();
+
+ jest.spyOn(integrationSettingsForm, 'toggleSubmitBtnState').mockImplementation(() => {});
+
+ return integrationSettingsForm.testSettings(formData).then(() => {
+ expect(integrationSettingsForm.toggleSubmitBtnState).toHaveBeenCalledWith(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issuable_spec.js b/spec/frontend/issuable_spec.js
new file mode 100644
index 00000000000..63c1fda2fb4
--- /dev/null
+++ b/spec/frontend/issuable_spec.js
@@ -0,0 +1,64 @@
+import $ from 'jquery';
+import MockAdaptor from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import IssuableIndex from '~/issuable_index';
+import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
+
+describe('Issuable', () => {
+ describe('initBulkUpdate', () => {
+ it('should not set bulkUpdateSidebar', () => {
+ new IssuableIndex('issue_'); // eslint-disable-line no-new
+
+ expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeNull();
+ });
+
+ it('should set bulkUpdateSidebar', () => {
+ const element = document.createElement('div');
+ element.classList.add('issues-bulk-update');
+ document.body.appendChild(element);
+
+ new IssuableIndex('issue_'); // eslint-disable-line no-new
+
+ expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeDefined();
+ });
+ });
+
+ describe('resetIncomingEmailToken', () => {
+ let mock;
+
+ beforeEach(() => {
+ const element = document.createElement('a');
+ element.classList.add('incoming-email-token-reset');
+ element.setAttribute('href', 'foo');
+ document.body.appendChild(element);
+
+ const input = document.createElement('input');
+ input.setAttribute('id', 'issuable_email');
+ document.body.appendChild(input);
+
+ new IssuableIndex('issue_'); // eslint-disable-line no-new
+
+ mock = new MockAdaptor(axios);
+
+ mock.onPut('foo').reply(200, {
+ new_address: 'testing123',
+ });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should send request to reset email token', done => {
+ jest.spyOn(axios, 'put');
+ document.querySelector('.incoming-email-token-reset').click();
+
+ setImmediate(() => {
+ expect(axios.put).toHaveBeenCalledWith('foo');
+ expect($('#issuable_email').val()).toBe('testing123');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js b/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js
new file mode 100644
index 00000000000..899010bdb0f
--- /dev/null
+++ b/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js
@@ -0,0 +1,121 @@
+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';
+
+describe('IssuableListRootApp', () => {
+ const issuesPath = 'gitlab-org/gitlab-test/-/issues';
+ const label = {
+ color: '#333',
+ title: 'jira-import::MTG-3',
+ };
+ let wrapper;
+
+ const findAlert = () => wrapper.find(GlAlert);
+
+ const findAlertLabel = () => wrapper.find(GlAlert).find(GlLabel);
+
+ const mountComponent = ({
+ isFinishedAlertShowing = false,
+ isInProgressAlertShowing = false,
+ isInProgress = false,
+ isFinished = false,
+ } = {}) =>
+ shallowMount(IssuableListRootApp, {
+ propsData: {
+ canEdit: true,
+ isJiraConfigured: true,
+ issuesPath,
+ projectPath: 'gitlab-org/gitlab-test',
+ },
+ data() {
+ return {
+ isFinishedAlertShowing,
+ isInProgressAlertShowing,
+ jiraImport: {
+ isInProgress,
+ isFinished,
+ label,
+ },
+ };
+ },
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when Jira import is not in progress', () => {
+ it('does not show an alert', () => {
+ wrapper = mountComponent();
+
+ expect(wrapper.contains(GlAlert)).toBe(false);
+ });
+ });
+
+ describe('when Jira import is in progress', () => {
+ it('shows an alert that tells the user a Jira import is in progress', () => {
+ wrapper = mountComponent({
+ isInProgressAlertShowing: true,
+ isInProgress: true,
+ });
+
+ expect(findAlert().text()).toBe(
+ 'Import in progress. Refresh page to see newly added issues.',
+ );
+ });
+ });
+
+ describe('when Jira import has finished', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ isFinishedAlertShowing: true,
+ isFinished: true,
+ });
+ });
+
+ describe('shows an alert', () => {
+ it('tells the user the Jira import has finished', () => {
+ expect(findAlert().text()).toBe('Issues successfully imported with the label');
+ });
+
+ it('contains the label title associated with the Jira import', () => {
+ const alertLabelTitle = findAlertLabel().props('title');
+
+ expect(alertLabelTitle).toBe(label.title);
+ });
+
+ it('contains the correct label color', () => {
+ const alertLabelTitle = findAlertLabel().props('backgroundColor');
+
+ expect(alertLabelTitle).toBe(label.color);
+ });
+
+ it('contains a link within the label', () => {
+ const alertLabelTarget = findAlertLabel().props('target');
+
+ expect(alertLabelTarget).toBe(
+ `${issuesPath}?label_name[]=${encodeURIComponent(label.title)}`,
+ );
+ });
+ });
+ });
+
+ describe('alert message', () => {
+ it('is hidden when dismissed', () => {
+ wrapper = mountComponent({
+ isInProgressAlertShowing: true,
+ isInProgress: true,
+ });
+
+ expect(wrapper.contains(GlAlert)).toBe(true);
+
+ findAlert().vm.$emit('dismiss');
+
+ return Vue.nextTick(() => {
+ expect(wrapper.contains(GlAlert)).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js
new file mode 100644
index 00000000000..a59d6d35ded
--- /dev/null
+++ b/spec/frontend/issue_show/components/app_spec.js
@@ -0,0 +1,497 @@
+import Vue from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+import { TEST_HOST } from 'helpers/test_constants';
+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';
+
+function formatText(text) {
+ return text.trim().replace(/\s\s+/g, ' ');
+}
+
+jest.mock('~/lib/utils/url_utility');
+jest.mock('~/issue_show/event_hub');
+
+const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
+
+describe('Issuable output', () => {
+ let mock;
+ let realtimeRequestCount = 0;
+ let vm;
+
+ beforeEach(() => {
+ setFixtures(`
+ <div>
+ <title>Title</title>
+ <div class="detail-page-description content-block">
+ <details open>
+ <summary>One</summary>
+ </details>
+ <details>
+ <summary>Two</summary>
+ </details>
+ </div>
+ <div class="flash-container"></div>
+ <span id="task_status"></span>
+ </div>
+ `);
+
+ const IssuableDescriptionComponent = Vue.extend(issuableApp);
+
+ mock = new MockAdapter(axios);
+ mock
+ .onGet('/gitlab-org/gitlab-shell/-/issues/9/realtime_changes/realtime_changes')
+ .reply(() => {
+ const res = Promise.resolve([200, REALTIME_REQUEST_STACK[realtimeRequestCount]]);
+ realtimeRequestCount += 1;
+ return res;
+ });
+
+ vm = new IssuableDescriptionComponent({
+ propsData: {
+ canUpdate: true,
+ canDestroy: true,
+ endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
+ updateEndpoint: TEST_HOST,
+ issuableRef: '#1',
+ initialTitleHtml: '',
+ initialTitleText: '',
+ initialDescriptionHtml: 'test',
+ initialDescriptionText: 'test',
+ lockVersion: 1,
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
+ projectNamespace: '/',
+ projectPath: '/',
+ issuableTemplateNamesPath: '/issuable-templates-path',
+ },
+ }).$mount();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ realtimeRequestCount = 0;
+
+ vm.poll.stop();
+ vm.$destroy();
+ });
+
+ it('should render a title/description/edited and update title/description/edited on update', () => {
+ let editedText;
+ return axios
+ .waitForAll()
+ .then(() => {
+ editedText = vm.$el.querySelector('.edited-text');
+ })
+ .then(() => {
+ expect(document.querySelector('title').innerText).toContain('this is a title (#1)');
+ expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>');
+ expect(vm.$el.querySelector('.md').innerHTML).toContain('<p>this is a description!</p>');
+ expect(vm.$el.querySelector('.js-task-list-field').value).toContain(
+ 'this is a description',
+ );
+
+ expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
+ expect(editedText.querySelector('.author-link').href).toMatch(/\/some_user$/);
+ expect(editedText.querySelector('time')).toBeTruthy();
+ expect(vm.state.lock_version).toEqual(1);
+ })
+ .then(() => {
+ vm.poll.makeRequest();
+ return axios.waitForAll();
+ })
+ .then(() => {
+ expect(document.querySelector('title').innerText).toContain('2 (#1)');
+ expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>');
+ expect(vm.$el.querySelector('.md').innerHTML).toContain('<p>42</p>');
+ expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42');
+ expect(vm.$el.querySelector('.edited-text')).toBeTruthy();
+ expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(
+ /Edited[\s\S]+?by Other User/,
+ );
+
+ expect(editedText.querySelector('.author-link').href).toMatch(/\/other_user$/);
+ expect(editedText.querySelector('time')).toBeTruthy();
+ expect(vm.state.lock_version).toEqual(2);
+ });
+ });
+
+ it('shows actions if permissions are correct', () => {
+ vm.showForm = true;
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.btn')).not.toBeNull();
+ });
+ });
+
+ it('does not show actions if permissions are incorrect', () => {
+ vm.showForm = true;
+ vm.canUpdate = false;
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.btn')).toBeNull();
+ });
+ });
+
+ it('does not update formState if form is already open', () => {
+ vm.updateAndShowForm();
+
+ vm.state.titleText = 'testing 123';
+
+ vm.updateAndShowForm();
+
+ return vm.$nextTick().then(() => {
+ expect(vm.store.formState.title).not.toBe('testing 123');
+ });
+ });
+
+ it('opens reCAPTCHA modal if update rejected as spam', () => {
+ let modal;
+
+ jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({
+ data: {
+ recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>',
+ },
+ });
+
+ vm.canUpdate = true;
+ vm.showForm = true;
+
+ return vm
+ .$nextTick()
+ .then(() => {
+ vm.$refs.recaptchaModal.scriptSrc = '//scriptsrc';
+ return vm.updateIssuable();
+ })
+ .then(() => {
+ modal = vm.$el.querySelector('.js-recaptcha-modal');
+
+ expect(modal.style.display).not.toEqual('none');
+ expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html');
+ expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc');
+ })
+ .then(() => {
+ modal.querySelector('.close').click();
+ return vm.$nextTick();
+ })
+ .then(() => {
+ expect(modal.style.display).toEqual('none');
+ expect(document.body.querySelector('.js-recaptcha-script')).toBeNull();
+ });
+ });
+
+ describe('updateIssuable', () => {
+ it('fetches new data after update', () => {
+ const updateStoreSpy = jest.spyOn(vm, 'updateStoreState');
+ const getDataSpy = jest.spyOn(vm.service, 'getData');
+ jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({
+ data: { web_url: window.location.pathname },
+ });
+
+ return vm.updateIssuable().then(() => {
+ expect(updateStoreSpy).toHaveBeenCalled();
+ expect(getDataSpy).toHaveBeenCalled();
+ });
+ });
+
+ it('correctly updates issuable data', () => {
+ const spy = jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({
+ data: { web_url: window.location.pathname },
+ });
+
+ return vm.updateIssuable().then(() => {
+ expect(spy).toHaveBeenCalledWith(vm.formState);
+ expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
+ });
+ });
+
+ it('does not redirect if issue has not moved', () => {
+ jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({
+ data: {
+ web_url: window.location.pathname,
+ confidential: vm.isConfidential,
+ },
+ });
+
+ return vm.updateIssuable().then(() => {
+ expect(visitUrl).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not redirect if issue has not moved and user has switched tabs', () => {
+ jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({
+ data: {
+ web_url: '',
+ confidential: vm.isConfidential,
+ },
+ });
+
+ return vm.updateIssuable().then(() => {
+ expect(visitUrl).not.toHaveBeenCalled();
+ });
+ });
+
+ it('redirects if returned web_url has changed', () => {
+ jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({
+ data: {
+ web_url: '/testing-issue-move',
+ confidential: vm.isConfidential,
+ },
+ });
+
+ vm.updateIssuable();
+
+ return vm.updateIssuable().then(() => {
+ expect(visitUrl).toHaveBeenCalledWith('/testing-issue-move');
+ });
+ });
+
+ describe('shows dialog when issue has unsaved changed', () => {
+ it('confirms on title change', () => {
+ vm.showForm = true;
+ vm.state.titleText = 'title has changed';
+ const e = { returnValue: null };
+ vm.handleBeforeUnloadEvent(e);
+ return vm.$nextTick().then(() => {
+ expect(e.returnValue).not.toBeNull();
+ });
+ });
+
+ it('confirms on description change', () => {
+ vm.showForm = true;
+ vm.state.descriptionText = 'description has changed';
+ const e = { returnValue: null };
+ vm.handleBeforeUnloadEvent(e);
+ return vm.$nextTick().then(() => {
+ expect(e.returnValue).not.toBeNull();
+ });
+ });
+
+ it('does nothing when nothing has changed', () => {
+ const e = { returnValue: null };
+ vm.handleBeforeUnloadEvent(e);
+ return vm.$nextTick().then(() => {
+ expect(e.returnValue).toBeNull();
+ });
+ });
+ });
+
+ describe('error when updating', () => {
+ it('closes form on error', () => {
+ jest.spyOn(vm.service, 'updateIssuable').mockRejectedValue();
+ return vm.updateIssuable().then(() => {
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `Error updating issue`,
+ );
+ });
+ });
+
+ it('returns the correct error message for issuableType', () => {
+ jest.spyOn(vm.service, 'updateIssuable').mockRejectedValue();
+ vm.issuableType = 'merge request';
+
+ return vm
+ .$nextTick()
+ .then(vm.updateIssuable)
+ .then(() => {
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `Error updating merge request`,
+ );
+ });
+ });
+
+ it('shows error message from backend if exists', () => {
+ const msg = 'Custom error message from backend';
+ jest
+ .spyOn(vm.service, 'updateIssuable')
+ .mockRejectedValue({ response: { data: { errors: [msg] } } });
+
+ return vm.updateIssuable().then(() => {
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `${vm.defaultErrorMessage}. ${msg}`,
+ );
+ });
+ });
+ });
+ });
+
+ describe('deleteIssuable', () => {
+ it('changes URL when deleted', () => {
+ jest.spyOn(vm.service, 'deleteIssuable').mockResolvedValue({
+ data: {
+ web_url: '/test',
+ },
+ });
+
+ return vm.deleteIssuable().then(() => {
+ expect(visitUrl).toHaveBeenCalledWith('/test');
+ });
+ });
+
+ it('stops polling when deleting', () => {
+ const spy = jest.spyOn(vm.poll, 'stop');
+ jest.spyOn(vm.service, 'deleteIssuable').mockResolvedValue({
+ data: {
+ web_url: '/test',
+ },
+ });
+
+ return vm.deleteIssuable().then(() => {
+ expect(spy).toHaveBeenCalledWith();
+ });
+ });
+
+ it('closes form on error', () => {
+ jest.spyOn(vm.service, 'deleteIssuable').mockRejectedValue();
+
+ return vm.deleteIssuable().then(() => {
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ 'Error deleting issue',
+ );
+ });
+ });
+ });
+
+ describe('updateAndShowForm', () => {
+ it('shows locked warning if form is open & data is different', () => {
+ return vm
+ .$nextTick()
+ .then(() => {
+ vm.updateAndShowForm();
+
+ vm.poll.makeRequest();
+
+ return new Promise(resolve => {
+ vm.$watch('formState.lockedWarningVisible', value => {
+ if (value) resolve();
+ });
+ });
+ })
+ .then(() => {
+ expect(vm.formState.lockedWarningVisible).toEqual(true);
+ expect(vm.formState.lock_version).toEqual(1);
+ expect(vm.$el.querySelector('.alert')).not.toBeNull();
+ });
+ });
+ });
+
+ describe('requestTemplatesAndShowForm', () => {
+ let formSpy;
+
+ beforeEach(() => {
+ formSpy = jest.spyOn(vm, 'updateAndShowForm');
+ });
+
+ it('shows the form if template names request is successful', () => {
+ const mockData = [{ name: 'Bug' }];
+ mock.onGet('/issuable-templates-path').reply(() => Promise.resolve([200, mockData]));
+
+ return vm.requestTemplatesAndShowForm().then(() => {
+ expect(formSpy).toHaveBeenCalledWith(mockData);
+ });
+ });
+
+ it('shows the form if template names request failed', () => {
+ mock
+ .onGet('/issuable-templates-path')
+ .reply(() => Promise.reject(new Error('something went wrong')));
+
+ return vm.requestTemplatesAndShowForm().then(() => {
+ expect(document.querySelector('.flash-container .flash-text').textContent).toContain(
+ 'Error updating issue',
+ );
+
+ expect(formSpy).toHaveBeenCalledWith();
+ });
+ });
+ });
+
+ describe('show inline edit button', () => {
+ it('should not render by default', () => {
+ expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined();
+ });
+
+ it('should render if showInlineEditButton', () => {
+ vm.showInlineEditButton = true;
+
+ expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined();
+ });
+ });
+
+ describe('updateStoreState', () => {
+ it('should make a request and update the state of the store', () => {
+ const data = { foo: 1 };
+ const getDataSpy = jest.spyOn(vm.service, 'getData').mockResolvedValue({ data });
+ const updateStateSpy = jest.spyOn(vm.store, 'updateState').mockImplementation(jest.fn);
+
+ return vm.updateStoreState().then(() => {
+ expect(getDataSpy).toHaveBeenCalled();
+ expect(updateStateSpy).toHaveBeenCalledWith(data);
+ });
+ });
+
+ it('should show error message if store update fails', () => {
+ jest.spyOn(vm.service, 'getData').mockRejectedValue();
+ vm.issuableType = 'merge request';
+
+ return vm.updateStoreState().then(() => {
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `Error updating ${vm.issuableType}`,
+ );
+ });
+ });
+ });
+
+ describe('issueChanged', () => {
+ beforeEach(() => {
+ vm.store.formState.title = '';
+ vm.store.formState.description = '';
+ vm.initialDescriptionText = '';
+ vm.initialTitleText = '';
+ });
+
+ it('returns true when title is changed', () => {
+ vm.store.formState.title = 'RandomText';
+
+ expect(vm.issueChanged).toBe(true);
+ });
+
+ it('returns false when title is empty null', () => {
+ vm.store.formState.title = null;
+
+ expect(vm.issueChanged).toBe(false);
+ });
+
+ it('returns false when `initialTitleText` is null and `formState.title` is empty string', () => {
+ vm.store.formState.title = '';
+ vm.initialTitleText = null;
+
+ expect(vm.issueChanged).toBe(false);
+ });
+
+ it('returns true when description is changed', () => {
+ vm.store.formState.description = 'RandomText';
+
+ expect(vm.issueChanged).toBe(true);
+ });
+
+ it('returns false when description is empty null', () => {
+ vm.store.formState.title = null;
+
+ expect(vm.issueChanged).toBe(false);
+ });
+
+ it('returns false when `initialDescriptionText` is null and `formState.description` is empty string', () => {
+ vm.store.formState.description = '';
+ vm.initialDescriptionText = null;
+
+ expect(vm.issueChanged).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issue_show/components/description_spec.js
new file mode 100644
index 00000000000..0053475dd13
--- /dev/null
+++ b/spec/frontend/issue_show/components/description_spec.js
@@ -0,0 +1,188 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import '~/behaviors/markdown/render_gfm';
+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';
+
+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);
+
+ if (!document.querySelector('.issuable-meta')) {
+ const metaData = document.createElement('div');
+ metaData.classList.add('issuable-meta');
+ metaData.innerHTML =
+ '<div class="flash-container"></div><span id="task_status"></span><span id="task_status_short"></span>';
+
+ document.body.appendChild(metaData);
+ }
+
+ vm = mountComponent(DescriptionComponent, props);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ afterAll(() => {
+ $('.issuable-meta .flash-container').remove();
+ });
+
+ it('animates description changes', () => {
+ vm.descriptionHtml = 'changed';
+
+ return vm
+ .$nextTick()
+ .then(() => {
+ expect(
+ vm.$el.querySelector('.md').classList.contains('issue-realtime-pre-pulse'),
+ ).toBeTruthy();
+ jest.runAllTimers();
+ return vm.$nextTick();
+ })
+ .then(() => {
+ expect(
+ vm.$el.querySelector('.md').classList.contains('issue-realtime-trigger-pulse'),
+ ).toBeTruthy();
+ });
+ });
+
+ it('opens reCAPTCHA dialog if update rejected as spam', () => {
+ let modal;
+ const recaptchaChild = vm.$children.find(
+ // eslint-disable-next-line no-underscore-dangle
+ child => child.$options._componentTag === 'recaptcha-modal',
+ );
+
+ recaptchaChild.scriptSrc = '//scriptsrc';
+
+ vm.taskListUpdateSuccess({
+ recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>',
+ });
+
+ return vm
+ .$nextTick()
+ .then(() => {
+ modal = vm.$el.querySelector('.js-recaptcha-modal');
+
+ expect(modal.style.display).not.toEqual('none');
+ expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html');
+ expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc');
+ })
+ .then(() => modal.querySelector('.close').click())
+ .then(() => vm.$nextTick())
+ .then(() => {
+ expect(modal.style.display).toEqual('none');
+ expect(document.body.querySelector('.js-recaptcha-script')).toBeNull();
+ });
+ });
+
+ it('applies syntax highlighting and math when description changed', () => {
+ const vmSpy = jest.spyOn(vm, 'renderGFM');
+ const prototypeSpy = jest.spyOn($.prototype, 'renderGFM');
+ vm.descriptionHtml = 'changed';
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$refs['gfm-content']).toBeDefined();
+ expect(vmSpy).toHaveBeenCalled();
+ expect(prototypeSpy).toHaveBeenCalled();
+ expect($.prototype.renderGFM).toHaveBeenCalled();
+ });
+ });
+
+ it('sets data-update-url', () => {
+ expect(vm.$el.querySelector('textarea').dataset.updateUrl).toEqual(TEST_HOST);
+ });
+
+ describe('TaskList', () => {
+ beforeEach(() => {
+ vm.$destroy();
+ TaskList.mockClear();
+ vm = mountComponent(DescriptionComponent, { ...props, issuableType: 'issuableType' });
+ });
+
+ it('re-inits the TaskList when description changed', () => {
+ vm.descriptionHtml = 'changed';
+
+ expect(TaskList).toHaveBeenCalled();
+ });
+
+ it('does not re-init the TaskList when canUpdate is false', () => {
+ vm.canUpdate = false;
+ vm.descriptionHtml = 'changed';
+
+ expect(TaskList).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls with issuableType dataType', () => {
+ vm.descriptionHtml = 'changed';
+
+ expect(TaskList).toHaveBeenCalledWith({
+ dataType: 'issuableType',
+ fieldName: 'description',
+ selector: '.detail-page-description',
+ onSuccess: expect.any(Function),
+ onError: expect.any(Function),
+ lockVersion: 0,
+ });
+ });
+ });
+
+ describe('taskStatus', () => {
+ it('adds full taskStatus', () => {
+ vm.taskStatus = '1 of 1';
+
+ return vm.$nextTick().then(() => {
+ expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe(
+ '1 of 1',
+ );
+ });
+ });
+
+ it('adds short taskStatus', () => {
+ vm.taskStatus = '1 of 1';
+
+ return vm.$nextTick().then(() => {
+ expect(document.querySelector('.issuable-meta #task_status_short').textContent.trim()).toBe(
+ '1/1 task',
+ );
+ });
+ });
+
+ it('clears task status text when no tasks are present', () => {
+ vm.taskStatus = '0 of 0';
+
+ return vm.$nextTick().then(() => {
+ expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe('');
+ });
+ });
+ });
+
+ describe('taskListUpdateError', () => {
+ it('should create flash notification and emit an event to parent', () => {
+ const msg =
+ 'Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again.';
+ const spy = jest.spyOn(vm, '$emit');
+
+ vm.taskListUpdateError();
+
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg);
+ expect(spy).toHaveBeenCalledWith('taskListUpdateFailed');
+ });
+ });
+});
diff --git a/spec/javascripts/issue_show/components/edited_spec.js b/spec/frontend/issue_show/components/edited_spec.js
index a1683f060c0..a1683f060c0 100644
--- a/spec/javascripts/issue_show/components/edited_spec.js
+++ b/spec/frontend/issue_show/components/edited_spec.js
diff --git a/spec/frontend/issue_show/components/fields/description_template_spec.js b/spec/frontend/issue_show/components/fields/description_template_spec.js
new file mode 100644
index 00000000000..9ebab31f1ad
--- /dev/null
+++ b/spec/frontend/issue_show/components/fields/description_template_spec.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import descriptionTemplate from '~/issue_show/components/fields/description_template.vue';
+
+describe('Issue description template component', () => {
+ let vm;
+ let formState;
+
+ beforeEach(() => {
+ const Component = Vue.extend(descriptionTemplate);
+ formState = {
+ description: 'test',
+ };
+
+ vm = new Component({
+ propsData: {
+ formState,
+ issuableTemplates: [{ name: 'test' }],
+ projectPath: '/',
+ projectNamespace: '/',
+ },
+ }).$mount();
+ });
+
+ it('renders templates as JSON array in data attribute', () => {
+ expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe(
+ '[{"name":"test"}]',
+ );
+ });
+
+ it('updates formState when changing template', () => {
+ vm.issuableTemplate.editor.setValue('test new template');
+
+ expect(formState.description).toBe('test new template');
+ });
+
+ it('returns formState description with editor getValue', () => {
+ formState.description = 'testing new template';
+
+ expect(vm.issuableTemplate.editor.getValue()).toBe('testing new template');
+ });
+});
diff --git a/spec/frontend/issue_show/components/form_spec.js b/spec/frontend/issue_show/components/form_spec.js
new file mode 100644
index 00000000000..b06a3a89d3b
--- /dev/null
+++ b/spec/frontend/issue_show/components/form_spec.js
@@ -0,0 +1,99 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import formComponent from '~/issue_show/components/form.vue';
+import Autosave from '~/autosave';
+import eventHub from '~/issue_show/event_hub';
+
+jest.mock('~/autosave');
+
+describe('Inline edit form component', () => {
+ let vm;
+ const defaultProps = {
+ canDestroy: true,
+ formState: {
+ title: 'b',
+ description: 'a',
+ lockedWarningVisible: false,
+ },
+ issuableType: 'issue',
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
+ projectPath: '/',
+ projectNamespace: '/',
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ const createComponent = props => {
+ const Component = Vue.extend(formComponent);
+
+ vm = mountComponent(Component, {
+ ...defaultProps,
+ ...props,
+ });
+ };
+
+ it('does not render template selector if no templates exist', () => {
+ createComponent();
+
+ expect(vm.$el.querySelector('.js-issuable-selector-wrap')).toBeNull();
+ });
+
+ it('renders template selector when templates exists', () => {
+ createComponent({ issuableTemplates: ['test'] });
+
+ expect(vm.$el.querySelector('.js-issuable-selector-wrap')).not.toBeNull();
+ });
+
+ it('hides locked warning by default', () => {
+ createComponent();
+
+ expect(vm.$el.querySelector('.alert')).toBeNull();
+ });
+
+ it('shows locked warning if formState is different', () => {
+ createComponent({ formState: { ...defaultProps.formState, lockedWarningVisible: true } });
+
+ expect(vm.$el.querySelector('.alert')).not.toBeNull();
+ });
+
+ it('hides locked warning when currently saving', () => {
+ createComponent({
+ formState: { ...defaultProps.formState, updateLoading: true, lockedWarningVisible: true },
+ });
+
+ expect(vm.$el.querySelector('.alert')).toBeNull();
+ });
+
+ describe('autosave', () => {
+ let spy;
+
+ beforeEach(() => {
+ spy = jest.spyOn(Autosave.prototype, 'reset');
+ });
+
+ it('initialized Autosave on mount', () => {
+ createComponent();
+
+ expect(Autosave).toHaveBeenCalledTimes(2);
+ });
+
+ it('calls reset on autosave when eventHub emits appropriate events', () => {
+ createComponent();
+
+ eventHub.$emit('close.form');
+
+ expect(spy).toHaveBeenCalledTimes(2);
+
+ eventHub.$emit('delete.issuable');
+
+ expect(spy).toHaveBeenCalledTimes(4);
+
+ eventHub.$emit('update.issuable');
+
+ expect(spy).toHaveBeenCalledTimes(6);
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/components/title_spec.js b/spec/frontend/issue_show/components/title_spec.js
new file mode 100644
index 00000000000..c274048fdd5
--- /dev/null
+++ b/spec/frontend/issue_show/components/title_spec.js
@@ -0,0 +1,95 @@
+import Vue from 'vue';
+import Store from '~/issue_show/stores';
+import titleComponent from '~/issue_show/components/title.vue';
+import eventHub from '~/issue_show/event_hub';
+
+describe('Title component', () => {
+ let vm;
+ beforeEach(() => {
+ setFixtures(`<title />`);
+
+ const Component = Vue.extend(titleComponent);
+ const store = new Store({
+ titleHtml: '',
+ descriptionHtml: '',
+ issuableRef: '',
+ });
+ vm = new Component({
+ propsData: {
+ issuableRef: '#1',
+ titleHtml: 'Testing <img />',
+ titleText: 'Testing',
+ showForm: false,
+ formState: store.formState,
+ },
+ }).$mount();
+ });
+
+ it('renders title HTML', () => {
+ expect(vm.$el.querySelector('.title').innerHTML.trim()).toBe('Testing <img>');
+ });
+
+ it('updates page title when changing titleHtml', () => {
+ const spy = jest.spyOn(vm, 'setPageTitle');
+ vm.titleHtml = 'test';
+
+ return vm.$nextTick().then(() => {
+ expect(spy).toHaveBeenCalled();
+ });
+ });
+
+ it('animates title changes', () => {
+ vm.titleHtml = 'test';
+ return vm
+ .$nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.title').classList).toContain('issue-realtime-pre-pulse');
+ jest.runAllTimers();
+ return vm.$nextTick();
+ })
+ .then(() => {
+ expect(vm.$el.querySelector('.title').classList).toContain('issue-realtime-trigger-pulse');
+ });
+ });
+
+ it('updates page title after changing title', () => {
+ vm.titleHtml = 'changed';
+ vm.titleText = 'changed';
+
+ return vm.$nextTick().then(() => {
+ expect(document.querySelector('title').textContent.trim()).toContain('changed');
+ });
+ });
+
+ describe('inline edit button', () => {
+ it('should not show by default', () => {
+ expect(vm.$el.querySelector('.btn-edit')).toBeNull();
+ });
+
+ it('should not show if canUpdate is false', () => {
+ vm.showInlineEditButton = true;
+ vm.canUpdate = false;
+
+ expect(vm.$el.querySelector('.btn-edit')).toBeNull();
+ });
+
+ it('should show if showInlineEditButton and canUpdate', () => {
+ vm.showInlineEditButton = true;
+ vm.canUpdate = true;
+
+ expect(vm.$el.querySelector('.btn-edit')).toBeDefined();
+ });
+
+ it('should trigger open.form event when clicked', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ vm.showInlineEditButton = true;
+ vm.canUpdate = true;
+
+ Vue.nextTick(() => {
+ vm.$el.querySelector('.btn-edit').click();
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('open.form');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jira_import/components/jira_import_app_spec.js b/spec/frontend/jira_import/components/jira_import_app_spec.js
index ce32559d5c9..0040e71c192 100644
--- a/spec/frontend/jira_import/components/jira_import_app_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_app_spec.js
@@ -1,5 +1,5 @@
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import JiraImportApp from '~/jira_import/components/jira_import_app.vue';
import JiraImportForm from '~/jira_import/components/jira_import_form.vue';
@@ -11,12 +11,16 @@ import { IMPORT_STATE } from '~/jira_import/utils';
const mountComponent = ({
isJiraConfigured = true,
errorMessage = '',
- showAlert = true,
+ selectedProject = 'MTG',
+ showAlert = false,
status = IMPORT_STATE.NONE,
loading = false,
mutate = jest.fn(() => Promise.resolve()),
-} = {}) =>
- shallowMount(JiraImportApp, {
+ mountType,
+} = {}) => {
+ const mountFunction = mountType === 'mount' ? mount : shallowMount;
+
+ return mountFunction(JiraImportApp, {
propsData: {
isJiraConfigured,
inProgressIllustration: 'in-progress-illustration.svg',
@@ -26,6 +30,7 @@ const mountComponent = ({
['My Second Jira Project', 'MSJP'],
['Migrate to GitLab', 'MTG'],
],
+ jiraIntegrationPath: 'gitlab-org/gitlab-test/-/services/jira/edit',
projectPath: 'gitlab-org/gitlab-test',
setupIllustration: 'setup-illustration.svg',
},
@@ -33,15 +38,32 @@ const mountComponent = ({
return {
errorMessage,
showAlert,
+ selectedProject,
jiraImportDetails: {
status,
- import: {
- jiraProjectKey: 'MTG',
- scheduledAt: '2020-04-08T12:17:25+00:00',
- scheduledBy: {
- name: 'Jane Doe',
+ imports: [
+ {
+ jiraProjectKey: 'MTG',
+ scheduledAt: '2020-04-08T10:11:12+00:00',
+ scheduledBy: {
+ name: 'John Doe',
+ },
},
- },
+ {
+ jiraProjectKey: 'MSJP',
+ scheduledAt: '2020-04-09T13:14:15+00:00',
+ scheduledBy: {
+ name: 'Jimmy Doe',
+ },
+ },
+ {
+ jiraProjectKey: 'MTG',
+ scheduledAt: '2020-04-09T16:17:18+00:00',
+ scheduledBy: {
+ name: 'Jane Doe',
+ },
+ },
+ ],
},
};
},
@@ -52,6 +74,7 @@ const mountComponent = ({
},
},
});
+};
describe('JiraImportApp', () => {
let wrapper;
@@ -159,6 +182,64 @@ describe('JiraImportApp', () => {
});
});
+ describe('import in progress screen', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({ status: IMPORT_STATE.SCHEDULED });
+ });
+
+ it('shows the illustration', () => {
+ expect(getProgressComponent().props('illustration')).toBe('in-progress-illustration.svg');
+ });
+
+ it('shows the name of the most recent import initiator', () => {
+ expect(getProgressComponent().props('importInitiator')).toBe('Jane Doe');
+ });
+
+ it('shows the name of the most recent imported project', () => {
+ expect(getProgressComponent().props('importProject')).toBe('MTG');
+ });
+
+ it('shows the time of the most recent import', () => {
+ expect(getProgressComponent().props('importTime')).toBe('2020-04-09T16:17:18+00:00');
+ });
+
+ it('has the path to the issues page', () => {
+ expect(getProgressComponent().props('issuesPath')).toBe('gitlab-org/gitlab-test/-/issues');
+ });
+ });
+
+ describe('jira import form screen', () => {
+ describe('when selected project has been imported before', () => {
+ it('shows jira-import::MTG-3 label since project MTG has been imported 2 time before', () => {
+ wrapper = mountComponent();
+
+ expect(getFormComponent().props('importLabel')).toBe('jira-import::MTG-3');
+ });
+
+ it('shows warning alert to explain project MTG has been imported 2 times before', () => {
+ wrapper = mountComponent({ mountType: 'mount' });
+
+ expect(getAlert().text()).toBe(
+ 'You have imported from this project 2 times before. Each new import will create duplicate issues.',
+ );
+ });
+ });
+
+ describe('when selected project has not been imported before', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({ selectedProject: 'MJP' });
+ });
+
+ it('shows jira-import::MJP-1 label since project MJP has not been imported before', () => {
+ expect(getFormComponent().props('importLabel')).toBe('jira-import::MJP-1');
+ });
+
+ it('does not show warning alert since project MJP has not been imported before', () => {
+ expect(getAlert().exists()).toBe(false);
+ });
+ });
+ });
+
describe('initiating a Jira import', () => {
it('calls the mutation with the expected arguments', () => {
const mutate = jest.fn(() => Promise.resolve());
@@ -200,6 +281,7 @@ describe('JiraImportApp', () => {
wrapper = mountComponent({
errorMessage: 'There was an error importing the Jira project.',
showAlert: true,
+ selectedProject: null,
});
expect(getAlert().exists()).toBe(true);
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 0987eb11693..dea94e7bf1f 100644
--- a/spec/frontend/jira_import/components/jira_import_form_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_form_spec.js
@@ -2,11 +2,15 @@ import { GlAvatar, GlButton, GlFormSelect, GlLabel } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import JiraImportForm from '~/jira_import/components/jira_import_form.vue';
+const importLabel = 'jira-import::MTG-1';
+const value = 'MTG';
+
const mountComponent = ({ mountType } = {}) => {
const mountFunction = mountType === 'mount' ? mount : shallowMount;
return mountFunction(JiraImportForm, {
propsData: {
+ importLabel,
issuesPath: 'gitlab-org/gitlab-test/-/issues',
jiraProjects: [
{
@@ -22,6 +26,7 @@ const mountComponent = ({ mountType } = {}) => {
value: 'MTG',
},
],
+ value,
},
});
};
@@ -29,6 +34,8 @@ const mountComponent = ({ mountType } = {}) => {
describe('JiraImportForm', () => {
let wrapper;
+ const getSelectDropdown = () => wrapper.find(GlFormSelect);
+
const getCancelButton = () => wrapper.findAll(GlButton).at(1);
afterEach(() => {
@@ -40,7 +47,7 @@ describe('JiraImportForm', () => {
it('is shown', () => {
wrapper = mountComponent();
- expect(wrapper.find(GlFormSelect).exists()).toBe(true);
+ expect(wrapper.contains(GlFormSelect)).toBe(true);
});
it('contains a list of Jira projects to select from', () => {
@@ -48,8 +55,7 @@ describe('JiraImportForm', () => {
const optionItems = ['My Jira Project', 'My Second Jira Project', 'Migrate to GitLab'];
- wrapper
- .find(GlFormSelect)
+ getSelectDropdown()
.findAll('option')
.wrappers.forEach((optionEl, index) => {
expect(optionEl.text()).toBe(optionItems[index]);
@@ -63,7 +69,7 @@ describe('JiraImportForm', () => {
});
it('shows a label which will be applied to imported Jira projects', () => {
- expect(wrapper.find(GlLabel).attributes('title')).toBe('jira-import::KEY-1');
+ expect(wrapper.find(GlLabel).props('title')).toBe(importLabel);
});
it('shows information to the user', () => {
@@ -77,7 +83,7 @@ describe('JiraImportForm', () => {
});
it('shows an avatar for the Reporter', () => {
- expect(wrapper.find(GlAvatar).exists()).toBe(true);
+ expect(wrapper.contains(GlAvatar)).toBe(true);
});
it('shows jira.issue.description.content for the Description', () => {
@@ -111,16 +117,19 @@ describe('JiraImportForm', () => {
});
});
- it('emits an "initiateJiraImport" event with the selected dropdown value when submitted', () => {
- const selectedOption = 'MTG';
+ it('emits an "input" event when the input select value changes', () => {
+ wrapper = mountComponent({ mountType: 'mount' });
+
+ getSelectDropdown().vm.$emit('change', value);
+ expect(wrapper.emitted('input')[0]).toEqual([value]);
+ });
+
+ it('emits an "initiateJiraImport" event with the selected dropdown value when submitted', () => {
wrapper = mountComponent();
- wrapper.setData({
- selectedOption,
- });
wrapper.find('form').trigger('submit');
- expect(wrapper.emitted('initiateJiraImport')[0]).toEqual([selectedOption]);
+ expect(wrapper.emitted('initiateJiraImport')[0]).toEqual([value]);
});
});
diff --git a/spec/frontend/jira_import/components/jira_import_progress_spec.js b/spec/frontend/jira_import/components/jira_import_progress_spec.js
index 9a6fc3b5925..3ccf14554e1 100644
--- a/spec/frontend/jira_import/components/jira_import_progress_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_progress_spec.js
@@ -2,10 +2,14 @@ import { GlEmptyState } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import JiraImportProgress from '~/jira_import/components/jira_import_progress.vue';
+const illustration = 'illustration.svg';
+const importProject = 'JIRAPROJECT';
+const issuesPath = 'gitlab-org/gitlab-test/-/issues';
+
describe('JiraImportProgress', () => {
let wrapper;
- const getGlEmptyStateAttribute = attribute => wrapper.find(GlEmptyState).attributes(attribute);
+ const getGlEmptyStateProp = attribute => wrapper.find(GlEmptyState).props(attribute);
const getParagraphText = () => wrapper.find('p').text();
@@ -13,11 +17,11 @@ describe('JiraImportProgress', () => {
const mountFunction = mountType === 'shallowMount' ? shallowMount : mount;
return mountFunction(JiraImportProgress, {
propsData: {
- illustration: 'illustration.svg',
+ illustration,
importInitiator: 'Jane Doe',
- importProject: 'JIRAPROJECT',
+ importProject,
importTime: '2020-04-08T12:17:25+00:00',
- issuesPath: 'gitlab-org/gitlab-test/-/issues',
+ issuesPath,
},
});
};
@@ -33,20 +37,21 @@ describe('JiraImportProgress', () => {
});
it('contains illustration', () => {
- expect(getGlEmptyStateAttribute('svgpath')).toBe('illustration.svg');
+ expect(getGlEmptyStateProp('svgPath')).toBe(illustration);
});
it('contains a title', () => {
const title = 'Import in progress';
- expect(getGlEmptyStateAttribute('title')).toBe(title);
+ expect(getGlEmptyStateProp('title')).toBe(title);
});
it('contains button text', () => {
- expect(getGlEmptyStateAttribute('primarybuttontext')).toBe('View issues');
+ expect(getGlEmptyStateProp('primaryButtonText')).toBe('View issues');
});
it('contains button url', () => {
- expect(getGlEmptyStateAttribute('primarybuttonlink')).toBe('gitlab-org/gitlab-test/-/issues');
+ const expected = `${issuesPath}?search=${importProject}`;
+ expect(getGlEmptyStateProp('primaryButtonLink')).toBe(expected);
});
});
diff --git a/spec/frontend/jira_import/components/jira_import_setup_spec.js b/spec/frontend/jira_import/components/jira_import_setup_spec.js
index 834c14b512e..aa94dc4f503 100644
--- a/spec/frontend/jira_import/components/jira_import_setup_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_setup_spec.js
@@ -2,15 +2,19 @@ import { GlEmptyState } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import JiraImportSetup from '~/jira_import/components/jira_import_setup.vue';
+const illustration = 'illustration.svg';
+const jiraIntegrationPath = 'gitlab-org/gitlab-test/-/services/jira/edit';
+
describe('JiraImportSetup', () => {
let wrapper;
- const getGlEmptyStateAttribute = attribute => wrapper.find(GlEmptyState).attributes(attribute);
+ const getGlEmptyStateProp = attribute => wrapper.find(GlEmptyState).props(attribute);
beforeEach(() => {
wrapper = shallowMount(JiraImportSetup, {
propsData: {
- illustration: 'illustration.svg',
+ illustration,
+ jiraIntegrationPath,
},
});
});
@@ -21,15 +25,19 @@ describe('JiraImportSetup', () => {
});
it('contains illustration', () => {
- expect(getGlEmptyStateAttribute('svgpath')).toBe('illustration.svg');
+ expect(getGlEmptyStateProp('svgPath')).toBe(illustration);
});
it('contains a description', () => {
const description = 'You will first need to set up Jira Integration to use this feature.';
- expect(getGlEmptyStateAttribute('description')).toBe(description);
+ expect(getGlEmptyStateProp('description')).toBe(description);
});
it('contains button text', () => {
- expect(getGlEmptyStateAttribute('primarybuttontext')).toBe('Set up Jira Integration');
+ expect(getGlEmptyStateProp('primaryButtonText')).toBe('Set up Jira Integration');
+ });
+
+ it('contains button link', () => {
+ expect(getGlEmptyStateProp('primaryButtonLink')).toBe(jiraIntegrationPath);
});
});
diff --git a/spec/frontend/jira_import/utils_spec.js b/spec/frontend/jira_import/utils_spec.js
index a14db104229..0b1edd6550a 100644
--- a/spec/frontend/jira_import/utils_spec.js
+++ b/spec/frontend/jira_import/utils_spec.js
@@ -1,27 +1,62 @@
-import { IMPORT_STATE, isInProgress } from '~/jira_import/utils';
+import {
+ calculateJiraImportLabel,
+ IMPORT_STATE,
+ isFinished,
+ isInProgress,
+} from '~/jira_import/utils';
describe('isInProgress', () => {
- it('returns true when state is IMPORT_STATE.SCHEDULED', () => {
- expect(isInProgress(IMPORT_STATE.SCHEDULED)).toBe(true);
+ it.each`
+ state | result
+ ${IMPORT_STATE.SCHEDULED} | ${true}
+ ${IMPORT_STATE.STARTED} | ${true}
+ ${IMPORT_STATE.FAILED} | ${false}
+ ${IMPORT_STATE.FINISHED} | ${false}
+ ${IMPORT_STATE.NONE} | ${false}
+ ${undefined} | ${false}
+ `('returns $result when state is $state', ({ state, result }) => {
+ expect(isInProgress(state)).toBe(result);
});
+});
- it('returns true when state is IMPORT_STATE.STARTED', () => {
- expect(isInProgress(IMPORT_STATE.STARTED)).toBe(true);
+describe('isFinished', () => {
+ it.each`
+ state | result
+ ${IMPORT_STATE.SCHEDULED} | ${false}
+ ${IMPORT_STATE.STARTED} | ${false}
+ ${IMPORT_STATE.FAILED} | ${false}
+ ${IMPORT_STATE.FINISHED} | ${true}
+ ${IMPORT_STATE.NONE} | ${false}
+ ${undefined} | ${false}
+ `('returns $result when state is $state', ({ state, result }) => {
+ expect(isFinished(state)).toBe(result);
});
+});
- it('returns false when state is IMPORT_STATE.FAILED', () => {
- expect(isInProgress(IMPORT_STATE.FAILED)).toBe(false);
- });
+describe('calculateJiraImportLabel', () => {
+ const jiraImports = [
+ { jiraProjectKey: 'MTG' },
+ { jiraProjectKey: 'MJP' },
+ { jiraProjectKey: 'MTG' },
+ { jiraProjectKey: 'MSJP' },
+ { jiraProjectKey: 'MTG' },
+ ];
- it('returns false when state is IMPORT_STATE.FINISHED', () => {
- expect(isInProgress(IMPORT_STATE.FINISHED)).toBe(false);
- });
+ const labels = [
+ { color: '#111', title: 'jira-import::MTG-1' },
+ { color: '#222', title: 'jira-import::MTG-2' },
+ { color: '#333', title: 'jira-import::MTG-3' },
+ ];
+
+ it('returns a label with the Jira project key and correct import count in the title', () => {
+ const label = calculateJiraImportLabel(jiraImports, labels);
- it('returns false when state is IMPORT_STATE.NONE', () => {
- expect(isInProgress(IMPORT_STATE.NONE)).toBe(false);
+ expect(label.title).toBe('jira-import::MTG-3');
});
- it('returns false when state is undefined', () => {
- expect(isInProgress()).toBe(false);
+ it('returns a label with the correct color', () => {
+ const label = calculateJiraImportLabel(jiraImports, labels);
+
+ expect(label.color).toBe('#333');
});
});
diff --git a/spec/javascripts/jobs/components/artifacts_block_spec.js b/spec/frontend/jobs/components/artifacts_block_spec.js
index 9cb56737f3e..9cb56737f3e 100644
--- a/spec/javascripts/jobs/components/artifacts_block_spec.js
+++ b/spec/frontend/jobs/components/artifacts_block_spec.js
diff --git a/spec/frontend/jobs/components/commit_block_spec.js b/spec/frontend/jobs/components/commit_block_spec.js
new file mode 100644
index 00000000000..4e2d0053831
--- /dev/null
+++ b/spec/frontend/jobs/components/commit_block_spec.js
@@ -0,0 +1,89 @@
+import Vue from 'vue';
+import component from '~/jobs/components/commit_block.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('Commit block', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ const props = {
+ commit: {
+ short_id: '1f0fb84f',
+ id: '1f0fb84fb6770d74d97eee58118fd3909cd4f48c',
+ commit_path: 'commit/1f0fb84fb6770d74d97eee58118fd3909cd4f48c',
+ title: 'Update README.md',
+ },
+ mergeRequest: {
+ iid: '!21244',
+ path: 'merge_requests/21244',
+ },
+ isLastBlock: true,
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('pipeline short sha', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ ...props,
+ });
+ });
+
+ it('renders pipeline short sha link', () => {
+ expect(vm.$el.querySelector('.js-commit-sha').getAttribute('href')).toEqual(
+ props.commit.commit_path,
+ );
+
+ expect(vm.$el.querySelector('.js-commit-sha').textContent.trim()).toEqual(
+ props.commit.short_id,
+ );
+ });
+
+ it('renders clipboard button', () => {
+ expect(vm.$el.querySelector('button').getAttribute('data-clipboard-text')).toEqual(
+ props.commit.id,
+ );
+ });
+ });
+
+ describe('with merge request', () => {
+ it('renders merge request link and reference', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ });
+
+ expect(vm.$el.querySelector('.js-link-commit').getAttribute('href')).toEqual(
+ props.mergeRequest.path,
+ );
+
+ expect(vm.$el.querySelector('.js-link-commit').textContent.trim()).toEqual(
+ `!${props.mergeRequest.iid}`,
+ );
+ });
+ });
+
+ describe('without merge request', () => {
+ it('does not render merge request', () => {
+ const copyProps = { ...props };
+ delete copyProps.mergeRequest;
+
+ vm = mountComponent(Component, {
+ ...copyProps,
+ });
+
+ expect(vm.$el.querySelector('.js-link-commit')).toBeNull();
+ });
+ });
+
+ describe('git commit title', () => {
+ it('renders git commit title', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ });
+
+ expect(vm.$el.textContent).toContain(props.commit.title);
+ });
+ });
+});
diff --git a/spec/javascripts/jobs/components/empty_state_spec.js b/spec/frontend/jobs/components/empty_state_spec.js
index c6eac4e27b3..c6eac4e27b3 100644
--- a/spec/javascripts/jobs/components/empty_state_spec.js
+++ b/spec/frontend/jobs/components/empty_state_spec.js
diff --git a/spec/javascripts/jobs/components/environments_block_spec.js b/spec/frontend/jobs/components/environments_block_spec.js
index 4f2359e83b6..4f2359e83b6 100644
--- a/spec/javascripts/jobs/components/environments_block_spec.js
+++ b/spec/frontend/jobs/components/environments_block_spec.js
diff --git a/spec/frontend/jobs/components/job_container_item_spec.js b/spec/frontend/jobs/components/job_container_item_spec.js
new file mode 100644
index 00000000000..9019504d22d
--- /dev/null
+++ b/spec/frontend/jobs/components/job_container_item_spec.js
@@ -0,0 +1,101 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import JobContainerItem from '~/jobs/components/job_container_item.vue';
+import job from '../mock_data';
+
+describe('JobContainerItem', () => {
+ const delayedJobFixture = getJSONFixture('jobs/delayed.json');
+ const Component = Vue.extend(JobContainerItem);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ const sharedTests = () => {
+ it('displays a status icon', () => {
+ expect(vm.$el).toHaveSpriteIcon(job.status.icon);
+ });
+
+ it('displays the job name', () => {
+ expect(vm.$el.innerText).toContain(job.name);
+ });
+
+ it('displays a link to the job', () => {
+ const link = vm.$el.querySelector('.js-job-link');
+
+ expect(link.href).toBe(job.status.details_path);
+ });
+ };
+
+ describe('when a job is not active and not retied', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ job,
+ isActive: false,
+ });
+ });
+
+ sharedTests();
+ });
+
+ describe('when a job is active', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ job,
+ isActive: true,
+ });
+ });
+
+ sharedTests();
+
+ it('displays an arrow', () => {
+ expect(vm.$el).toHaveSpriteIcon('arrow-right');
+ });
+ });
+
+ describe('when a job is retried', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ job: {
+ ...job,
+ retried: true,
+ },
+ isActive: false,
+ });
+ });
+
+ sharedTests();
+
+ it('displays an icon', () => {
+ expect(vm.$el).toHaveSpriteIcon('retry');
+ });
+ });
+
+ describe('for delayed job', () => {
+ beforeEach(() => {
+ const remainingMilliseconds = 1337000;
+ jest
+ .spyOn(Date, 'now')
+ .mockImplementation(
+ () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingMilliseconds,
+ );
+ });
+
+ it('displays remaining time in tooltip', done => {
+ vm = mountComponent(Component, {
+ job: delayedJobFixture,
+ isActive: false,
+ });
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.js-job-link').getAttribute('data-original-title')).toEqual(
+ 'delayed job - delayed manual action (00:22:17)',
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/job_log_spec.js b/spec/frontend/jobs/components/job_log_spec.js
new file mode 100644
index 00000000000..2bb1e0af3a2
--- /dev/null
+++ b/spec/frontend/jobs/components/job_log_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import component from '~/jobs/components/job_log.vue';
+import createStore from '~/jobs/store';
+import { resetStore } from '../store/helpers';
+
+describe('Job Log', () => {
+ const Component = Vue.extend(component);
+ let store;
+ let vm;
+
+ const trace =
+ '<span>Running with gitlab-runner 12.1.0 (de7731dd)<br/></span><span> on docker-auto-scale-com d5ae8d25<br/></span><div class="append-right-8" data-timestamp="1565502765" data-section="prepare-executor" role="button"></div><span class="section section-header js-s-prepare-executor">Using Docker executor with image ruby:2.6 ...<br/></span>';
+
+ beforeEach(() => {
+ store = createStore();
+ });
+
+ afterEach(() => {
+ resetStore(store);
+ vm.$destroy();
+ });
+
+ it('renders provided trace', () => {
+ vm = mountComponentWithStore(Component, {
+ props: {
+ trace,
+ isComplete: true,
+ },
+ store,
+ });
+
+ expect(vm.$el.querySelector('code').textContent).toContain(
+ 'Running with gitlab-runner 12.1.0 (de7731dd)',
+ );
+ });
+
+ describe('while receiving trace', () => {
+ it('renders animation', () => {
+ vm = mountComponentWithStore(Component, {
+ props: {
+ trace,
+ isComplete: false,
+ },
+ store,
+ });
+
+ expect(vm.$el.querySelector('.js-log-animation')).not.toBeNull();
+ });
+ });
+
+ describe('when build trace has finishes', () => {
+ it('does not render animation', () => {
+ vm = mountComponentWithStore(Component, {
+ props: {
+ trace,
+ isComplete: true,
+ },
+ store,
+ });
+
+ expect(vm.$el.querySelector('.js-log-animation')).toBeNull();
+ });
+ });
+});
diff --git a/spec/javascripts/jobs/components/jobs_container_spec.js b/spec/frontend/jobs/components/jobs_container_spec.js
index 119b18b7557..119b18b7557 100644
--- a/spec/javascripts/jobs/components/jobs_container_spec.js
+++ b/spec/frontend/jobs/components/jobs_container_spec.js
diff --git a/spec/frontend/jobs/components/log/line_header_spec.js b/spec/frontend/jobs/components/log/line_header_spec.js
index f2e202674ee..5ce69221dab 100644
--- a/spec/frontend/jobs/components/log/line_header_spec.js
+++ b/spec/frontend/jobs/components/log/line_header_spec.js
@@ -86,7 +86,7 @@ describe('Job Log Header Line', () => {
describe('with duration', () => {
beforeEach(() => {
- createComponent(Object.assign({}, data, { duration: '00:10' }));
+ createComponent({ ...data, duration: '00:10' });
});
it('renders the duration badge', () => {
diff --git a/spec/javascripts/jobs/components/manual_variables_form_spec.js b/spec/frontend/jobs/components/manual_variables_form_spec.js
index 82fd73ef033..82fd73ef033 100644
--- a/spec/javascripts/jobs/components/manual_variables_form_spec.js
+++ b/spec/frontend/jobs/components/manual_variables_form_spec.js
diff --git a/spec/frontend/jobs/components/sidebar_spec.js b/spec/frontend/jobs/components/sidebar_spec.js
new file mode 100644
index 00000000000..0c8e2dc3aef
--- /dev/null
+++ b/spec/frontend/jobs/components/sidebar_spec.js
@@ -0,0 +1,166 @@
+import Vue from 'vue';
+import sidebarDetailsBlock from '~/jobs/components/sidebar.vue';
+import createStore from '~/jobs/store';
+import job, { jobsInStage } from '../mock_data';
+import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { trimText } from '../../helpers/text_helper';
+
+describe('Sidebar details block', () => {
+ const SidebarComponent = Vue.extend(sidebarDetailsBlock);
+ let vm;
+ let store;
+
+ beforeEach(() => {
+ store = createStore();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('when there is no retry path retry', () => {
+ it('should not render a retry button', () => {
+ const copy = { ...job };
+ delete copy.retry_path;
+
+ store.dispatch('receiveJobSuccess', copy);
+ vm = mountComponentWithStore(SidebarComponent, {
+ store,
+ });
+
+ expect(vm.$el.querySelector('.js-retry-button')).toBeNull();
+ });
+ });
+
+ describe('without terminal path', () => {
+ it('does not render terminal link', () => {
+ store.dispatch('receiveJobSuccess', job);
+ vm = mountComponentWithStore(SidebarComponent, { store });
+
+ expect(vm.$el.querySelector('.js-terminal-link')).toBeNull();
+ });
+ });
+
+ describe('with terminal path', () => {
+ it('renders terminal link', () => {
+ store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' });
+ vm = mountComponentWithStore(SidebarComponent, {
+ store,
+ });
+
+ expect(vm.$el.querySelector('.js-terminal-link')).not.toBeNull();
+ });
+ });
+
+ beforeEach(() => {
+ store.dispatch('receiveJobSuccess', job);
+ vm = mountComponentWithStore(SidebarComponent, { store });
+ });
+
+ describe('actions', () => {
+ it('should render link to new issue', () => {
+ expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual(
+ job.new_issue_path,
+ );
+
+ expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue');
+ });
+
+ it('should render link to retry job', () => {
+ expect(vm.$el.querySelector('.js-retry-button').getAttribute('href')).toEqual(job.retry_path);
+ });
+
+ it('should render link to cancel job', () => {
+ expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(job.cancel_path);
+ });
+ });
+
+ describe('information', () => {
+ it('should render job duration', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-duration').textContent)).toEqual(
+ 'Duration: 6 seconds',
+ );
+ });
+
+ it('should render erased date', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-erased').textContent)).toEqual(
+ 'Erased: 3 weeks ago',
+ );
+ });
+
+ it('should render finished date', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-finished').textContent)).toEqual(
+ 'Finished: 3 weeks ago',
+ );
+ });
+
+ it('should render queued date', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-queued').textContent)).toEqual(
+ 'Queued: 9 seconds',
+ );
+ });
+
+ it('should render runner ID', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-runner').textContent)).toEqual(
+ 'Runner: local ci runner (#1)',
+ );
+ });
+
+ it('should render timeout information', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-timeout').textContent)).toEqual(
+ 'Timeout: 1m 40s (from runner)',
+ );
+ });
+
+ it('should render coverage', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-coverage').textContent)).toEqual(
+ 'Coverage: 20%',
+ );
+ });
+
+ it('should render tags', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-tags').textContent)).toEqual('Tags: tag');
+ });
+ });
+
+ describe('stages dropdown', () => {
+ beforeEach(() => {
+ store.dispatch('receiveJobSuccess', job);
+ });
+
+ describe('with stages', () => {
+ beforeEach(() => {
+ vm = mountComponentWithStore(SidebarComponent, { store });
+ });
+
+ it('renders value provided as selectedStage as selected', () => {
+ expect(vm.$el.querySelector('.js-selected-stage').textContent.trim()).toEqual(
+ vm.selectedStage,
+ );
+ });
+ });
+
+ describe('without jobs for stages', () => {
+ beforeEach(() => {
+ store.dispatch('receiveJobSuccess', job);
+ vm = mountComponentWithStore(SidebarComponent, { store });
+ });
+
+ it('does not render job container', () => {
+ expect(vm.$el.querySelector('.js-jobs-container')).toBeNull();
+ });
+ });
+
+ describe('with jobs for stages', () => {
+ beforeEach(() => {
+ store.dispatch('receiveJobSuccess', job);
+ store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses);
+ vm = mountComponentWithStore(SidebarComponent, { store });
+ });
+
+ it('renders list of jobs', () => {
+ expect(vm.$el.querySelector('.js-jobs-container')).not.toBeNull();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/stages_dropdown_spec.js b/spec/frontend/jobs/components/stages_dropdown_spec.js
new file mode 100644
index 00000000000..e8fa6094c25
--- /dev/null
+++ b/spec/frontend/jobs/components/stages_dropdown_spec.js
@@ -0,0 +1,163 @@
+import Vue from 'vue';
+import { trimText } from 'helpers/text_helper';
+import component from '~/jobs/components/stages_dropdown.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('Stages Dropdown', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ const mockPipelineData = {
+ id: 28029444,
+ details: {
+ status: {
+ details_path: '/gitlab-org/gitlab-foss/pipelines/28029444',
+ group: 'success',
+ has_details: true,
+ icon: 'status_success',
+ label: 'passed',
+ text: 'passed',
+ tooltip: 'passed',
+ },
+ },
+ path: 'pipeline/28029444',
+ flags: {
+ merge_request_pipeline: true,
+ detached_merge_request_pipeline: false,
+ },
+ merge_request: {
+ iid: 1234,
+ path: '/root/detached-merge-request-pipelines/-/merge_requests/1',
+ title: 'Update README.md',
+ source_branch: 'feature-1234',
+ source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1234',
+ target_branch: 'master',
+ target_branch_path: '/root/detached-merge-request-pipelines/branches/master',
+ },
+ ref: {
+ name: 'test-branch',
+ },
+ };
+
+ describe('without a merge request pipeline', () => {
+ let pipeline;
+
+ beforeEach(() => {
+ pipeline = JSON.parse(JSON.stringify(mockPipelineData));
+ delete pipeline.merge_request;
+ delete pipeline.flags.merge_request_pipeline;
+ delete pipeline.flags.detached_merge_request_pipeline;
+
+ vm = mountComponent(Component, {
+ pipeline,
+ stages: [{ name: 'build' }, { name: 'test' }],
+ selectedStage: 'deploy',
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders pipeline status', () => {
+ expect(vm.$el.querySelector('.js-ci-status-icon-success')).not.toBeNull();
+ });
+
+ it('renders pipeline link', () => {
+ expect(vm.$el.querySelector('.js-pipeline-path').getAttribute('href')).toEqual(
+ 'pipeline/28029444',
+ );
+ });
+
+ it('renders dropdown with stages', () => {
+ expect(vm.$el.querySelector('.dropdown .js-stage-item').textContent).toContain('build');
+ });
+
+ it('rendes selected stage', () => {
+ expect(vm.$el.querySelector('.dropdown .js-selected-stage').textContent).toContain('deploy');
+ });
+
+ it(`renders the pipeline info text like "Pipeline #123 for source_branch"`, () => {
+ const expected = `Pipeline #${pipeline.id} for ${pipeline.ref.name}`;
+ const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText);
+
+ expect(actual).toBe(expected);
+ });
+ });
+
+ describe('with an "attached" merge request pipeline', () => {
+ let pipeline;
+
+ beforeEach(() => {
+ pipeline = JSON.parse(JSON.stringify(mockPipelineData));
+ pipeline.flags.merge_request_pipeline = true;
+ pipeline.flags.detached_merge_request_pipeline = false;
+
+ vm = mountComponent(Component, {
+ pipeline,
+ stages: [],
+ selectedStage: 'deploy',
+ });
+ });
+
+ it(`renders the pipeline info text like "Pipeline #123 for !456 with source_branch into target_branch"`, () => {
+ const expected = `Pipeline #${pipeline.id} for !${pipeline.merge_request.iid} with ${pipeline.merge_request.source_branch} into ${pipeline.merge_request.target_branch}`;
+ const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText);
+
+ expect(actual).toBe(expected);
+ });
+
+ it(`renders the correct merge request link`, () => {
+ const actual = vm.$el.querySelector('.js-mr-link').href;
+
+ expect(actual).toContain(pipeline.merge_request.path);
+ });
+
+ it(`renders the correct source branch link`, () => {
+ const actual = vm.$el.querySelector('.js-source-branch-link').href;
+
+ expect(actual).toContain(pipeline.merge_request.source_branch_path);
+ });
+
+ it(`renders the correct target branch link`, () => {
+ const actual = vm.$el.querySelector('.js-target-branch-link').href;
+
+ expect(actual).toContain(pipeline.merge_request.target_branch_path);
+ });
+ });
+
+ describe('with a detached merge request pipeline', () => {
+ let pipeline;
+
+ beforeEach(() => {
+ pipeline = JSON.parse(JSON.stringify(mockPipelineData));
+ pipeline.flags.merge_request_pipeline = false;
+ pipeline.flags.detached_merge_request_pipeline = true;
+
+ vm = mountComponent(Component, {
+ pipeline,
+ stages: [],
+ selectedStage: 'deploy',
+ });
+ });
+
+ it(`renders the pipeline info like "Pipeline #123 for !456 with source_branch"`, () => {
+ const expected = `Pipeline #${pipeline.id} for !${pipeline.merge_request.iid} with ${pipeline.merge_request.source_branch}`;
+ const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText);
+
+ expect(actual).toBe(expected);
+ });
+
+ it(`renders the correct merge request link`, () => {
+ const actual = vm.$el.querySelector('.js-mr-link').href;
+
+ expect(actual).toContain(pipeline.merge_request.path);
+ });
+
+ it(`renders the correct source branch link`, () => {
+ const actual = vm.$el.querySelector('.js-source-branch-link').href;
+
+ expect(actual).toContain(pipeline.merge_request.source_branch_path);
+ });
+ });
+});
diff --git a/spec/javascripts/jobs/components/trigger_block_spec.js b/spec/frontend/jobs/components/trigger_block_spec.js
index 448197b82c0..448197b82c0 100644
--- a/spec/javascripts/jobs/components/trigger_block_spec.js
+++ b/spec/frontend/jobs/components/trigger_block_spec.js
diff --git a/spec/javascripts/jobs/components/unmet_prerequisites_block_spec.js b/spec/frontend/jobs/components/unmet_prerequisites_block_spec.js
index 68fcb321214..68fcb321214 100644
--- a/spec/javascripts/jobs/components/unmet_prerequisites_block_spec.js
+++ b/spec/frontend/jobs/components/unmet_prerequisites_block_spec.js
diff --git a/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js
new file mode 100644
index 00000000000..2f7a6030650
--- /dev/null
+++ b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js
@@ -0,0 +1,79 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
+
+describe('DelayedJobMixin', () => {
+ const delayedJobFixture = getJSONFixture('jobs/delayed.json');
+ const dummyComponent = Vue.extend({
+ mixins: [delayedJobMixin],
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ render(createElement) {
+ return createElement('div', this.remainingTime);
+ },
+ });
+
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ jest.clearAllTimers();
+ });
+
+ describe('if job is empty object', () => {
+ beforeEach(() => {
+ vm = mountComponent(dummyComponent, {
+ job: {},
+ });
+ });
+
+ it('sets remaining time to 00:00:00', () => {
+ expect(vm.$el.innerText).toBe('00:00:00');
+ });
+
+ describe('after mounting', () => {
+ beforeEach(() => vm.$nextTick());
+
+ it('does not update remaining time', () => {
+ expect(vm.$el.innerText).toBe('00:00:00');
+ });
+ });
+ });
+
+ describe('if job is delayed job', () => {
+ let remainingTimeInMilliseconds = 42000;
+
+ beforeEach(() => {
+ jest
+ .spyOn(Date, 'now')
+ .mockImplementation(
+ () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingTimeInMilliseconds,
+ );
+
+ vm = mountComponent(dummyComponent, {
+ job: delayedJobFixture,
+ });
+ });
+
+ describe('after mounting', () => {
+ beforeEach(() => vm.$nextTick());
+
+ it('sets remaining time', () => {
+ expect(vm.$el.innerText).toBe('00:00:42');
+ });
+
+ it('updates remaining time', () => {
+ remainingTimeInMilliseconds = 41000;
+ jest.advanceTimersByTime(1000);
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.innerText).toBe('00:00:41');
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jobs/store/actions_spec.js b/spec/frontend/jobs/store/actions_spec.js
new file mode 100644
index 00000000000..91bd5521f70
--- /dev/null
+++ b/spec/frontend/jobs/store/actions_spec.js
@@ -0,0 +1,512 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import { TEST_HOST } from '../../helpers/test_constants';
+import axios from '~/lib/utils/axios_utils';
+import {
+ setJobEndpoint,
+ setTraceOptions,
+ clearEtagPoll,
+ stopPolling,
+ requestJob,
+ fetchJob,
+ receiveJobSuccess,
+ receiveJobError,
+ scrollTop,
+ scrollBottom,
+ requestTrace,
+ fetchTrace,
+ startPollingTrace,
+ stopPollingTrace,
+ receiveTraceSuccess,
+ receiveTraceError,
+ toggleCollapsibleLine,
+ requestJobsForStage,
+ fetchJobsForStage,
+ receiveJobsForStageSuccess,
+ receiveJobsForStageError,
+ hideSidebar,
+ showSidebar,
+ toggleSidebar,
+} from '~/jobs/store/actions';
+import state from '~/jobs/store/state';
+import * as types from '~/jobs/store/mutation_types';
+
+describe('Job State actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('setJobEndpoint', () => {
+ it('should commit SET_JOB_ENDPOINT mutation', done => {
+ testAction(
+ setJobEndpoint,
+ 'job/872324.json',
+ mockedState,
+ [{ type: types.SET_JOB_ENDPOINT, payload: 'job/872324.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setTraceOptions', () => {
+ it('should commit SET_TRACE_OPTIONS mutation', done => {
+ testAction(
+ setTraceOptions,
+ { pagePath: 'job/872324/trace.json' },
+ mockedState,
+ [{ type: types.SET_TRACE_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('hideSidebar', () => {
+ it('should commit HIDE_SIDEBAR mutation', done => {
+ testAction(hideSidebar, null, mockedState, [{ type: types.HIDE_SIDEBAR }], [], done);
+ });
+ });
+
+ describe('showSidebar', () => {
+ it('should commit HIDE_SIDEBAR mutation', done => {
+ testAction(showSidebar, null, mockedState, [{ type: types.SHOW_SIDEBAR }], [], done);
+ });
+ });
+
+ describe('toggleSidebar', () => {
+ describe('when isSidebarOpen is true', () => {
+ it('should dispatch hideSidebar', done => {
+ testAction(toggleSidebar, null, mockedState, [], [{ type: 'hideSidebar' }], done);
+ });
+ });
+
+ describe('when isSidebarOpen is false', () => {
+ it('should dispatch showSidebar', done => {
+ mockedState.isSidebarOpen = false;
+
+ testAction(toggleSidebar, null, mockedState, [], [{ type: 'showSidebar' }], done);
+ });
+ });
+ });
+
+ describe('requestJob', () => {
+ it('should commit REQUEST_JOB mutation', done => {
+ testAction(requestJob, null, mockedState, [{ type: types.REQUEST_JOB }], [], done);
+ });
+ });
+
+ describe('fetchJob', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.jobEndpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ stopPolling();
+ clearEtagPoll();
+ });
+
+ describe('success', () => {
+ it('dispatches requestJob and receiveJobSuccess ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 121212, name: 'karma' });
+
+ testAction(
+ fetchJob,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestJob',
+ },
+ {
+ payload: { id: 121212, name: 'karma' },
+ type: 'receiveJobSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
+ });
+
+ it('dispatches requestJob and receiveJobError ', done => {
+ testAction(
+ fetchJob,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestJob',
+ },
+ {
+ type: 'receiveJobError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('receiveJobSuccess', () => {
+ it('should commit RECEIVE_JOB_SUCCESS mutation', done => {
+ testAction(
+ receiveJobSuccess,
+ { id: 121232132 },
+ mockedState,
+ [{ type: types.RECEIVE_JOB_SUCCESS, payload: { id: 121232132 } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveJobError', () => {
+ it('should commit RECEIVE_JOB_ERROR mutation', done => {
+ testAction(receiveJobError, null, mockedState, [{ type: types.RECEIVE_JOB_ERROR }], [], done);
+ });
+ });
+
+ describe('scrollTop', () => {
+ it('should dispatch toggleScrollButtons action', done => {
+ testAction(scrollTop, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done);
+ });
+ });
+
+ describe('scrollBottom', () => {
+ it('should dispatch toggleScrollButtons action', done => {
+ testAction(scrollBottom, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done);
+ });
+ });
+
+ describe('requestTrace', () => {
+ it('should commit REQUEST_TRACE mutation', done => {
+ testAction(requestTrace, null, mockedState, [{ type: types.REQUEST_TRACE }], [], done);
+ });
+ });
+
+ describe('fetchTrace', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.traceEndpoint = `${TEST_HOST}/endpoint`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ stopPolling();
+ clearEtagPoll();
+ });
+
+ describe('success', () => {
+ it('dispatches requestTrace, receiveTraceSuccess and stopPollingTrace when job is complete', done => {
+ mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, {
+ html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
+ complete: true,
+ });
+
+ testAction(
+ fetchTrace,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'toggleScrollisInBottom',
+ payload: true,
+ },
+ {
+ payload: {
+ html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
+ complete: true,
+ },
+ type: 'receiveTraceSuccess',
+ },
+ {
+ type: 'stopPollingTrace',
+ },
+ ],
+ done,
+ );
+ });
+
+ describe('when job is incomplete', () => {
+ let tracePayload;
+
+ beforeEach(() => {
+ tracePayload = {
+ html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
+ complete: false,
+ };
+
+ mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, tracePayload);
+ });
+
+ it('dispatches startPollingTrace', done => {
+ testAction(
+ fetchTrace,
+ null,
+ mockedState,
+ [],
+ [
+ { type: 'toggleScrollisInBottom', payload: true },
+ { type: 'receiveTraceSuccess', payload: tracePayload },
+ { type: 'startPollingTrace' },
+ ],
+ done,
+ );
+ });
+
+ it('does not dispatch startPollingTrace when timeout is non-empty', done => {
+ mockedState.traceTimeout = 1;
+
+ testAction(
+ fetchTrace,
+ null,
+ mockedState,
+ [],
+ [
+ { type: 'toggleScrollisInBottom', payload: true },
+ { type: 'receiveTraceSuccess', payload: tracePayload },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(500);
+ });
+
+ it('dispatches requestTrace and receiveTraceError ', done => {
+ testAction(
+ fetchTrace,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'receiveTraceError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('startPollingTrace', () => {
+ let dispatch;
+ let commit;
+
+ beforeEach(() => {
+ dispatch = jest.fn();
+ commit = jest.fn();
+
+ startPollingTrace({ dispatch, commit });
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ });
+
+ it('should save the timeout id but not call fetchTrace', () => {
+ expect(commit).toHaveBeenCalledWith(types.SET_TRACE_TIMEOUT, expect.any(Number));
+ expect(commit.mock.calls[0][1]).toBeGreaterThan(0);
+
+ expect(dispatch).not.toHaveBeenCalledWith('fetchTrace');
+ });
+
+ describe('after timeout has passed', () => {
+ beforeEach(() => {
+ jest.advanceTimersByTime(4000);
+ });
+
+ it('should clear the timeout id and fetchTrace', () => {
+ expect(commit).toHaveBeenCalledWith(types.SET_TRACE_TIMEOUT, 0);
+ expect(dispatch).toHaveBeenCalledWith('fetchTrace');
+ });
+ });
+ });
+
+ describe('stopPollingTrace', () => {
+ let origTimeout;
+
+ beforeEach(() => {
+ // Can't use spyOn(window, 'clearTimeout') because this caused unrelated specs to timeout
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23838#note_280277727
+ origTimeout = window.clearTimeout;
+ window.clearTimeout = jest.fn();
+ });
+
+ afterEach(() => {
+ window.clearTimeout = origTimeout;
+ });
+
+ it('should commit STOP_POLLING_TRACE mutation ', done => {
+ const traceTimeout = 7;
+
+ testAction(
+ stopPollingTrace,
+ null,
+ { ...mockedState, traceTimeout },
+ [{ type: types.SET_TRACE_TIMEOUT, payload: 0 }, { type: types.STOP_POLLING_TRACE }],
+ [],
+ )
+ .then(() => {
+ expect(window.clearTimeout).toHaveBeenCalledWith(traceTimeout);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('receiveTraceSuccess', () => {
+ it('should commit RECEIVE_TRACE_SUCCESS mutation ', done => {
+ testAction(
+ receiveTraceSuccess,
+ 'hello world',
+ mockedState,
+ [{ type: types.RECEIVE_TRACE_SUCCESS, payload: 'hello world' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveTraceError', () => {
+ it('should commit stop polling trace', done => {
+ testAction(receiveTraceError, null, mockedState, [], [{ type: 'stopPollingTrace' }], done);
+ });
+ });
+
+ describe('toggleCollapsibleLine', () => {
+ it('should commit TOGGLE_COLLAPSIBLE_LINE mutation ', done => {
+ testAction(
+ toggleCollapsibleLine,
+ { isClosed: true },
+ mockedState,
+ [{ type: types.TOGGLE_COLLAPSIBLE_LINE, payload: { isClosed: true } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestJobsForStage', () => {
+ it('should commit REQUEST_JOBS_FOR_STAGE mutation ', done => {
+ testAction(
+ requestJobsForStage,
+ { name: 'deploy' },
+ mockedState,
+ [{ type: types.REQUEST_JOBS_FOR_STAGE, payload: { name: 'deploy' } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchJobsForStage', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ it('dispatches requestJobsForStage and receiveJobsForStageSuccess ', done => {
+ mock
+ .onGet(`${TEST_HOST}/jobs.json`)
+ .replyOnce(200, { latest_statuses: [{ id: 121212, name: 'build' }], retried: [] });
+
+ testAction(
+ fetchJobsForStage,
+ { dropdown_path: `${TEST_HOST}/jobs.json` },
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestJobsForStage',
+ payload: { dropdown_path: `${TEST_HOST}/jobs.json` },
+ },
+ {
+ payload: [{ id: 121212, name: 'build' }],
+ type: 'receiveJobsForStageSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onGet(`${TEST_HOST}/jobs.json`).reply(500);
+ });
+
+ it('dispatches requestJobsForStage and receiveJobsForStageError', done => {
+ testAction(
+ fetchJobsForStage,
+ { dropdown_path: `${TEST_HOST}/jobs.json` },
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestJobsForStage',
+ payload: { dropdown_path: `${TEST_HOST}/jobs.json` },
+ },
+ {
+ type: 'receiveJobsForStageError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('receiveJobsForStageSuccess', () => {
+ it('should commit RECEIVE_JOBS_FOR_STAGE_SUCCESS mutation ', done => {
+ testAction(
+ receiveJobsForStageSuccess,
+ [{ id: 121212, name: 'karma' }],
+ mockedState,
+ [{ type: types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, payload: [{ id: 121212, name: 'karma' }] }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveJobsForStageError', () => {
+ it('should commit RECEIVE_JOBS_FOR_STAGE_ERROR mutation ', done => {
+ testAction(
+ receiveJobsForStageError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_JOBS_FOR_STAGE_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/javascripts/jobs/store/helpers.js b/spec/frontend/jobs/store/helpers.js
index 81a769b4a6e..81a769b4a6e 100644
--- a/spec/javascripts/jobs/store/helpers.js
+++ b/spec/frontend/jobs/store/helpers.js
diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js
index d77690ffac0..3557d3b94b6 100644
--- a/spec/frontend/jobs/store/mutations_spec.js
+++ b/spec/frontend/jobs/store/mutations_spec.js
@@ -59,7 +59,7 @@ describe('Jobs Store Mutations', () => {
describe('when traceSize is bigger than the total size', () => {
it('sets isTraceSizeVisible to false', () => {
- const copy = Object.assign({}, stateCopy, { traceSize: 5118460, size: 2321312 });
+ const copy = { ...stateCopy, traceSize: 5118460, size: 2321312 };
mutations[types.RECEIVE_TRACE_SUCCESS](copy, { total: 511846 });
diff --git a/spec/frontend/labels_select_spec.js b/spec/frontend/labels_select_spec.js
index 5f48bad4970..8b08eb9e124 100644
--- a/spec/frontend/labels_select_spec.js
+++ b/spec/frontend/labels_select_spec.js
@@ -45,7 +45,6 @@ describe('LabelsSelect', () => {
labels: mockLabels,
issueUpdateURL: mockUrl,
enableScopedLabels: true,
- scopedLabelsDocumentationLink: 'docs-link',
}),
);
});
@@ -71,10 +70,6 @@ describe('LabelsSelect', () => {
it('generated label item has a gl-label-text class', () => {
expect($labelEl.find('span').hasClass('gl-label-text')).toEqual(true);
});
-
- it('generated label item template does not have gl-label-icon class', () => {
- expect($labelEl.find('.gl-label-icon')).toHaveLength(0);
- });
});
describe('when scoped label is present', () => {
@@ -87,7 +82,6 @@ describe('LabelsSelect', () => {
labels: mockScopedLabels,
issueUpdateURL: mockUrl,
enableScopedLabels: true,
- scopedLabelsDocumentationLink: 'docs-link',
}),
);
});
@@ -106,14 +100,6 @@ describe('LabelsSelect', () => {
expect($labelEl.find('a').attr('data-html')).toBe('true');
});
- it('generated label item template has question icon', () => {
- expect($labelEl.find('i.fa-question-circle')).toHaveLength(1);
- });
-
- it('generated label item template has gl-label-icon class', () => {
- expect($labelEl.find('.gl-label-icon')).toHaveLength(1);
- });
-
it('generated label item template has correct label styles', () => {
expect($labelEl.find('span.gl-label-text').attr('style')).toBe(
`background-color: ${label.color}; color: ${label.text_color};`,
@@ -141,7 +127,6 @@ describe('LabelsSelect', () => {
labels: mockScopedLabels2,
issueUpdateURL: mockUrl,
enableScopedLabels: true,
- scopedLabelsDocumentationLink: 'docs-link',
}),
);
});
diff --git a/spec/frontend/landing_spec.js b/spec/frontend/landing_spec.js
new file mode 100644
index 00000000000..448d8ee2e81
--- /dev/null
+++ b/spec/frontend/landing_spec.js
@@ -0,0 +1,184 @@
+import Cookies from 'js-cookie';
+import Landing from '~/landing';
+
+describe('Landing', () => {
+ const test = {};
+
+ describe('class constructor', () => {
+ beforeEach(() => {
+ test.landingElement = {};
+ test.dismissButton = {};
+ test.cookieName = 'cookie_name';
+
+ test.landing = new Landing(test.landingElement, test.dismissButton, test.cookieName);
+ });
+
+ it('should set .landing', () => {
+ expect(test.landing.landingElement).toBe(test.landingElement);
+ });
+
+ it('should set .cookieName', () => {
+ expect(test.landing.cookieName).toBe(test.cookieName);
+ });
+
+ it('should set .dismissButton', () => {
+ expect(test.landing.dismissButton).toBe(test.dismissButton);
+ });
+
+ it('should set .eventWrapper', () => {
+ expect(test.landing.eventWrapper).toEqual({});
+ });
+ });
+
+ describe('toggle', () => {
+ beforeEach(() => {
+ test.isDismissed = false;
+ test.landingElement = {
+ classList: {
+ toggle: jest.fn(),
+ },
+ };
+ test.landing = {
+ isDismissed: () => {},
+ addEvents: () => {},
+ landingElement: test.landingElement,
+ };
+
+ jest.spyOn(test.landing, 'isDismissed').mockReturnValue(test.isDismissed);
+ jest.spyOn(test.landing, 'addEvents').mockImplementation(() => {});
+
+ Landing.prototype.toggle.call(test.landing);
+ });
+
+ it('should call .isDismissed', () => {
+ expect(test.landing.isDismissed).toHaveBeenCalled();
+ });
+
+ it('should call .classList.toggle', () => {
+ expect(test.landingElement.classList.toggle).toHaveBeenCalledWith('hidden', test.isDismissed);
+ });
+
+ it('should call .addEvents', () => {
+ expect(test.landing.addEvents).toHaveBeenCalled();
+ });
+
+ describe('if isDismissed is true', () => {
+ beforeEach(() => {
+ test.isDismissed = true;
+ test.landingElement = {
+ classList: {
+ toggle: jest.fn(),
+ },
+ };
+ test.landing = {
+ isDismissed: () => {},
+ addEvents: () => {},
+ landingElement: test.landingElement,
+ };
+
+ jest.spyOn(test.landing, 'isDismissed').mockReturnValue(test.isDismissed);
+ jest.spyOn(test.landing, 'addEvents').mockImplementation(() => {});
+
+ test.landing.isDismissed.mockClear();
+
+ Landing.prototype.toggle.call(test.landing);
+ });
+
+ it('should not call .addEvents', () => {
+ expect(test.landing.addEvents).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('addEvents', () => {
+ beforeEach(() => {
+ test.dismissButton = {
+ addEventListener: jest.fn(),
+ };
+ test.eventWrapper = {};
+ test.landing = {
+ eventWrapper: test.eventWrapper,
+ dismissButton: test.dismissButton,
+ dismissLanding: () => {},
+ };
+
+ Landing.prototype.addEvents.call(test.landing);
+ });
+
+ it('should set .eventWrapper.dismissLanding', () => {
+ expect(test.eventWrapper.dismissLanding).toEqual(expect.any(Function));
+ });
+
+ it('should call .addEventListener', () => {
+ expect(test.dismissButton.addEventListener).toHaveBeenCalledWith(
+ 'click',
+ test.eventWrapper.dismissLanding,
+ );
+ });
+ });
+
+ describe('removeEvents', () => {
+ beforeEach(() => {
+ test.dismissButton = {
+ removeEventListener: jest.fn(),
+ };
+ test.eventWrapper = { dismissLanding: () => {} };
+ test.landing = {
+ eventWrapper: test.eventWrapper,
+ dismissButton: test.dismissButton,
+ };
+
+ Landing.prototype.removeEvents.call(test.landing);
+ });
+
+ it('should call .removeEventListener', () => {
+ expect(test.dismissButton.removeEventListener).toHaveBeenCalledWith(
+ 'click',
+ test.eventWrapper.dismissLanding,
+ );
+ });
+ });
+
+ describe('dismissLanding', () => {
+ beforeEach(() => {
+ test.landingElement = {
+ classList: {
+ add: jest.fn(),
+ },
+ };
+ test.cookieName = 'cookie_name';
+ test.landing = { landingElement: test.landingElement, cookieName: test.cookieName };
+
+ jest.spyOn(Cookies, 'set').mockImplementation(() => {});
+
+ Landing.prototype.dismissLanding.call(test.landing);
+ });
+
+ it('should call .classList.add', () => {
+ expect(test.landingElement.classList.add).toHaveBeenCalledWith('hidden');
+ });
+
+ it('should call Cookies.set', () => {
+ expect(Cookies.set).toHaveBeenCalledWith(test.cookieName, 'true', { expires: 365 });
+ });
+ });
+
+ describe('isDismissed', () => {
+ beforeEach(() => {
+ test.cookieName = 'cookie_name';
+ test.landing = { cookieName: test.cookieName };
+
+ jest.spyOn(Cookies, 'get').mockReturnValue('true');
+
+ test.isDismissed = Landing.prototype.isDismissed.call(test.landing);
+ });
+
+ it('should call Cookies.get', () => {
+ expect(Cookies.get).toHaveBeenCalledWith(test.cookieName);
+ });
+
+ it('should return a boolean', () => {
+ expect(typeof test.isDismissed).toEqual('boolean');
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/axios_utils_spec.js b/spec/frontend/lib/utils/axios_utils_spec.js
index d5c39567f06..1585a38ae86 100644
--- a/spec/frontend/lib/utils/axios_utils_spec.js
+++ b/spec/frontend/lib/utils/axios_utils_spec.js
@@ -11,6 +11,7 @@ describe('axios_utils', () => {
mock = new AxiosMockAdapter(axios);
mock.onAny('/ok').reply(200);
mock.onAny('/err').reply(500);
+ // eslint-disable-next-line jest/no-standalone-expect
expect(axios.countActiveRequests()).toBe(0);
});
diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js
index 1edfda30fec..c8dc90c9ace 100644
--- a/spec/frontend/lib/utils/common_utils_spec.js
+++ b/spec/frontend/lib/utils/common_utils_spec.js
@@ -503,7 +503,7 @@ describe('common_utils', () => {
beforeEach(() => {
window.gon = window.gon || {};
- beforeGon = Object.assign({}, window.gon);
+ beforeGon = { ...window.gon };
window.gon.sprite_icons = 'icons.svg';
});
diff --git a/spec/frontend/lib/utils/csrf_token_spec.js b/spec/frontend/lib/utils/csrf_token_spec.js
new file mode 100644
index 00000000000..1b98ef126e9
--- /dev/null
+++ b/spec/frontend/lib/utils/csrf_token_spec.js
@@ -0,0 +1,57 @@
+import csrf from '~/lib/utils/csrf';
+import { setHTMLFixture } from 'helpers/fixtures';
+
+describe('csrf', () => {
+ let testContext;
+
+ beforeEach(() => {
+ testContext = {};
+ });
+
+ beforeEach(() => {
+ testContext.tokenKey = 'X-CSRF-Token';
+ testContext.token =
+ 'pH1cvjnP9grx2oKlhWEDvUZnJ8x2eXsIs1qzyHkF3DugSG5yTxR76CWeEZRhML2D1IeVB7NEW0t5l/axE4iJpQ==';
+ });
+
+ it('returns the correct headerKey', () => {
+ expect(csrf.headerKey).toBe(testContext.tokenKey);
+ });
+
+ describe('when csrf token is in the DOM', () => {
+ beforeEach(() => {
+ setHTMLFixture(`
+ <meta name="csrf-token" content="${testContext.token}">
+ `);
+
+ csrf.init();
+ });
+
+ it('returns the csrf token', () => {
+ expect(csrf.token).toBe(testContext.token);
+ });
+
+ it('returns the csrf headers object', () => {
+ expect(csrf.headers[testContext.tokenKey]).toBe(testContext.token);
+ });
+ });
+
+ describe('when csrf token is not in the DOM', () => {
+ beforeEach(() => {
+ setHTMLFixture(`
+ <meta name="some-other-token">
+ `);
+
+ csrf.init();
+ });
+
+ it('returns null for token', () => {
+ expect(csrf.token).toBeNull();
+ });
+
+ it('returns empty object for headers', () => {
+ expect(typeof csrf.headers).toBe('object');
+ expect(Object.keys(csrf.headers).length).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/downloader_spec.js b/spec/frontend/lib/utils/downloader_spec.js
new file mode 100644
index 00000000000..c14cba3a62b
--- /dev/null
+++ b/spec/frontend/lib/utils/downloader_spec.js
@@ -0,0 +1,40 @@
+import downloader from '~/lib/utils/downloader';
+
+describe('Downloader', () => {
+ let a;
+
+ beforeEach(() => {
+ a = { click: jest.fn() };
+ jest.spyOn(document, 'createElement').mockImplementation(() => a);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('when inline file content is provided', () => {
+ const fileData = 'inline content';
+ const fileName = 'test.csv';
+
+ it('uses the data urls to download the file', () => {
+ downloader({ fileName, fileData });
+ expect(document.createElement).toHaveBeenCalledWith('a');
+ expect(a.download).toBe(fileName);
+ expect(a.href).toBe(`data:text/plain;base64,${fileData}`);
+ expect(a.click).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('when an endpoint is provided', () => {
+ const url = 'https://gitlab.com/test.csv';
+ const fileName = 'test.csv';
+
+ it('uses the endpoint to download the file', () => {
+ downloader({ fileName, url });
+ expect(document.createElement).toHaveBeenCalledWith('a');
+ expect(a.download).toBe(fileName);
+ expect(a.href).toBe(url);
+ expect(a.click).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/navigation_utility_spec.js b/spec/frontend/lib/utils/navigation_utility_spec.js
new file mode 100644
index 00000000000..88172f38894
--- /dev/null
+++ b/spec/frontend/lib/utils/navigation_utility_spec.js
@@ -0,0 +1,23 @@
+import findAndFollowLink from '~/lib/utils/navigation_utility';
+import { visitUrl } from '~/lib/utils/url_utility';
+
+jest.mock('~/lib/utils/url_utility');
+
+describe('findAndFollowLink', () => {
+ it('visits a link when the selector exists', () => {
+ const href = '/some/path';
+
+ setFixtures(`<a class="my-shortcut" href="${href}">link</a>`);
+
+ findAndFollowLink('.my-shortcut');
+
+ expect(visitUrl).toHaveBeenCalledWith(href);
+ });
+
+ it('does not throw an exception when the selector does not exist', () => {
+ // this should not throw an exception
+ findAndFollowLink('.this-selector-does-not-exist');
+
+ expect(visitUrl).not.toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/lib/utils/poll_spec.js b/spec/frontend/lib/utils/poll_spec.js
new file mode 100644
index 00000000000..5ee9738ebf3
--- /dev/null
+++ b/spec/frontend/lib/utils/poll_spec.js
@@ -0,0 +1,225 @@
+import Poll from '~/lib/utils/poll';
+import { successCodes } from '~/lib/utils/http_status';
+import waitForPromises from 'helpers/wait_for_promises';
+
+describe('Poll', () => {
+ let callbacks;
+ let service;
+
+ function setup() {
+ return new Poll({
+ resource: service,
+ method: 'fetch',
+ successCallback: callbacks.success,
+ errorCallback: callbacks.error,
+ notificationCallback: callbacks.notification,
+ }).makeRequest();
+ }
+
+ const mockServiceCall = (response, shouldFail = false) => {
+ const value = {
+ ...response,
+ header: response.header || {},
+ };
+
+ if (shouldFail) {
+ service.fetch.mockRejectedValue(value);
+ } else {
+ service.fetch.mockResolvedValue(value);
+ }
+ };
+
+ const waitForAllCallsToFinish = (waitForCount, successCallback) => {
+ if (!waitForCount) {
+ return Promise.resolve().then(successCallback());
+ }
+
+ jest.runOnlyPendingTimers();
+
+ return waitForPromises().then(() => waitForAllCallsToFinish(waitForCount - 1, successCallback));
+ };
+
+ beforeEach(() => {
+ service = {
+ fetch: jest.fn(),
+ };
+ callbacks = {
+ success: jest.fn(),
+ error: jest.fn(),
+ notification: jest.fn(),
+ };
+ });
+
+ it('calls the success callback when no header for interval is provided', done => {
+ mockServiceCall({ status: 200 });
+ setup();
+
+ waitForAllCallsToFinish(1, () => {
+ expect(callbacks.success).toHaveBeenCalled();
+ expect(callbacks.error).not.toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('calls the error callback when the http request returns an error', done => {
+ mockServiceCall({ status: 500 }, true);
+ setup();
+
+ waitForAllCallsToFinish(1, () => {
+ expect(callbacks.success).not.toHaveBeenCalled();
+ expect(callbacks.error).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('skips the error callback when request is aborted', done => {
+ mockServiceCall({ status: 0 }, true);
+ setup();
+
+ waitForAllCallsToFinish(1, () => {
+ expect(callbacks.success).not.toHaveBeenCalled();
+ expect(callbacks.error).not.toHaveBeenCalled();
+ expect(callbacks.notification).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('should call the success callback when the interval header is -1', done => {
+ mockServiceCall({ status: 200, headers: { 'poll-interval': -1 } });
+ setup()
+ .then(() => {
+ expect(callbacks.success).toHaveBeenCalled();
+ expect(callbacks.error).not.toHaveBeenCalled();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ describe('for 2xx status code', () => {
+ successCodes.forEach(httpCode => {
+ it(`starts polling when http status is ${httpCode} and interval header is provided`, done => {
+ mockServiceCall({ status: httpCode, headers: { 'poll-interval': 1 } });
+
+ const Polling = new Poll({
+ resource: service,
+ method: 'fetch',
+ data: { page: 1 },
+ successCallback: callbacks.success,
+ errorCallback: callbacks.error,
+ });
+
+ Polling.makeRequest();
+
+ waitForAllCallsToFinish(2, () => {
+ Polling.stop();
+
+ expect(service.fetch.mock.calls).toHaveLength(2);
+ expect(service.fetch).toHaveBeenCalledWith({ page: 1 });
+ expect(callbacks.success).toHaveBeenCalled();
+ expect(callbacks.error).not.toHaveBeenCalled();
+
+ done();
+ });
+ });
+ });
+ });
+
+ describe('stop', () => {
+ it('stops polling when method is called', done => {
+ mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } });
+
+ const Polling = new Poll({
+ resource: service,
+ method: 'fetch',
+ data: { page: 1 },
+ successCallback: () => {
+ Polling.stop();
+ },
+ errorCallback: callbacks.error,
+ });
+
+ jest.spyOn(Polling, 'stop');
+
+ Polling.makeRequest();
+
+ waitForAllCallsToFinish(1, () => {
+ expect(service.fetch.mock.calls).toHaveLength(1);
+ expect(service.fetch).toHaveBeenCalledWith({ page: 1 });
+ expect(Polling.stop).toHaveBeenCalled();
+
+ done();
+ });
+ });
+ });
+
+ describe('enable', () => {
+ it('should enable polling upon a response', done => {
+ mockServiceCall({ status: 200 });
+ const Polling = new Poll({
+ resource: service,
+ method: 'fetch',
+ data: { page: 1 },
+ successCallback: () => {},
+ });
+
+ Polling.enable({
+ data: { page: 4 },
+ response: { status: 200, headers: { 'poll-interval': 1 } },
+ });
+
+ waitForAllCallsToFinish(1, () => {
+ Polling.stop();
+
+ expect(service.fetch.mock.calls).toHaveLength(1);
+ expect(service.fetch).toHaveBeenCalledWith({ page: 4 });
+ expect(Polling.options.data).toEqual({ page: 4 });
+ done();
+ });
+ });
+ });
+
+ describe('restart', () => {
+ it('should restart polling when its called', done => {
+ mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } });
+
+ const Polling = new Poll({
+ resource: service,
+ method: 'fetch',
+ data: { page: 1 },
+ successCallback: () => {
+ Polling.stop();
+
+ // Let's pretend that we asynchronously restart this.
+ // setTimeout is mocked but this will actually get triggered
+ // in waitForAllCalssToFinish.
+ setTimeout(() => {
+ Polling.restart({ data: { page: 4 } });
+ }, 1);
+ },
+ errorCallback: callbacks.error,
+ });
+
+ jest.spyOn(Polling, 'stop');
+ jest.spyOn(Polling, 'enable');
+ jest.spyOn(Polling, 'restart');
+
+ Polling.makeRequest();
+
+ waitForAllCallsToFinish(2, () => {
+ Polling.stop();
+
+ expect(service.fetch.mock.calls).toHaveLength(2);
+ expect(service.fetch).toHaveBeenCalledWith({ page: 4 });
+ expect(Polling.stop).toHaveBeenCalled();
+ expect(Polling.enable).toHaveBeenCalled();
+ expect(Polling.restart).toHaveBeenCalled();
+ expect(Polling.options.data).toEqual({ page: 4 });
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/sticky_spec.js b/spec/frontend/lib/utils/sticky_spec.js
new file mode 100644
index 00000000000..4ad68cc9ff6
--- /dev/null
+++ b/spec/frontend/lib/utils/sticky_spec.js
@@ -0,0 +1,77 @@
+import { isSticky } from '~/lib/utils/sticky';
+import { setHTMLFixture } from 'helpers/fixtures';
+
+const TEST_OFFSET_TOP = 500;
+
+describe('sticky', () => {
+ let el;
+ let offsetTop;
+
+ beforeEach(() => {
+ setHTMLFixture(
+ `
+ <div class="parent">
+ <div id="js-sticky"></div>
+ </div>
+ `,
+ );
+
+ offsetTop = TEST_OFFSET_TOP;
+ el = document.getElementById('js-sticky');
+ Object.defineProperty(el, 'offsetTop', {
+ get() {
+ return offsetTop;
+ },
+ });
+ });
+
+ afterEach(() => {
+ el = null;
+ });
+
+ describe('when stuck', () => {
+ it('does not remove is-stuck class', () => {
+ isSticky(el, 0, el.offsetTop);
+ isSticky(el, 0, el.offsetTop);
+
+ expect(el.classList.contains('is-stuck')).toBeTruthy();
+ });
+
+ it('adds is-stuck class', () => {
+ isSticky(el, 0, el.offsetTop);
+
+ expect(el.classList.contains('is-stuck')).toBeTruthy();
+ });
+
+ it('inserts placeholder element', () => {
+ isSticky(el, 0, el.offsetTop, true);
+
+ expect(document.querySelector('.sticky-placeholder')).not.toBeNull();
+ });
+ });
+
+ describe('when not stuck', () => {
+ it('removes is-stuck class', () => {
+ jest.spyOn(el.classList, 'remove');
+
+ isSticky(el, 0, el.offsetTop);
+ isSticky(el, 0, 0);
+
+ expect(el.classList.remove).toHaveBeenCalledWith('is-stuck');
+ expect(el.classList.contains('is-stuck')).toBe(false);
+ });
+
+ it('does not add is-stuck class', () => {
+ isSticky(el, 0, 0);
+
+ expect(el.classList.contains('is-stuck')).toBeFalsy();
+ });
+
+ it('removes placeholder', () => {
+ isSticky(el, 0, el.offsetTop, true);
+ isSticky(el, 0, 0, true);
+
+ expect(document.querySelector('.sticky-placeholder')).toBeNull();
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js
index ba3e4020e66..1d616a7da0b 100644
--- a/spec/frontend/lib/utils/text_markdown_spec.js
+++ b/spec/frontend/lib/utils/text_markdown_spec.js
@@ -25,7 +25,7 @@ describe('init markdown', () => {
insertMarkdownText({
textArea,
text: textArea.value,
- tag: '*',
+ tag: '* ',
blockTag: null,
selected: '',
wrap: false,
@@ -43,7 +43,7 @@ describe('init markdown', () => {
insertMarkdownText({
textArea,
text: textArea.value,
- tag: '*',
+ tag: '* ',
blockTag: null,
selected: '',
wrap: false,
@@ -61,7 +61,7 @@ describe('init markdown', () => {
insertMarkdownText({
textArea,
text: textArea.value,
- tag: '*',
+ tag: '* ',
blockTag: null,
selected: '',
wrap: false,
@@ -79,7 +79,7 @@ describe('init markdown', () => {
insertMarkdownText({
textArea,
text: textArea.value,
- tag: '*',
+ tag: '* ',
blockTag: null,
selected: '',
wrap: false,
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index 4960895890f..c494033badd 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -91,36 +91,75 @@ describe('URL utility', () => {
});
describe('mergeUrlParams', () => {
+ const { mergeUrlParams } = urlUtils;
+
it('adds w', () => {
- expect(urlUtils.mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag');
- expect(urlUtils.mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag');
- expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path')).toBe('https://host/path?w=1');
- expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path#frag')).toBe(
- 'https://host/path?w=1#frag',
- );
+ expect(mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag');
+ expect(mergeUrlParams({ w: 1 }, '')).toBe('?w=1');
+ expect(mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag');
+ expect(mergeUrlParams({ w: 1 }, 'https://host/path')).toBe('https://host/path?w=1');
+ expect(mergeUrlParams({ w: 1 }, 'https://host/path#frag')).toBe('https://host/path?w=1#frag');
+ expect(mergeUrlParams({ w: 1 }, 'https://h/p?k1=v1#frag')).toBe('https://h/p?k1=v1&w=1#frag');
+ expect(mergeUrlParams({ w: 'null' }, '')).toBe('?w=null');
+ });
- expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://h/p?k1=v1#frag')).toBe(
- 'https://h/p?k1=v1&w=1#frag',
- );
+ it('adds multiple params', () => {
+ expect(mergeUrlParams({ a: 1, b: 2, c: 3 }, '#frag')).toBe('?a=1&b=2&c=3#frag');
});
it('updates w', () => {
- expect(urlUtils.mergeUrlParams({ w: 1 }, '?k1=v1&w=0#frag')).toBe('?k1=v1&w=1#frag');
+ expect(mergeUrlParams({ w: 2 }, '/path?w=1#frag')).toBe('/path?w=2#frag');
+ expect(mergeUrlParams({ w: 2 }, 'https://host/path?w=1')).toBe('https://host/path?w=2');
});
- it('adds multiple params', () => {
- expect(urlUtils.mergeUrlParams({ a: 1, b: 2, c: 3 }, '#frag')).toBe('?a=1&b=2&c=3#frag');
+ it('removes null w', () => {
+ expect(mergeUrlParams({ w: null }, '?w=1#frag')).toBe('#frag');
+ expect(mergeUrlParams({ w: null }, '/path?w=1#frag')).toBe('/path#frag');
+ expect(mergeUrlParams({ w: null }, 'https://host/path?w=1')).toBe('https://host/path');
+ expect(mergeUrlParams({ w: null }, 'https://host/path?w=1#frag')).toBe(
+ 'https://host/path#frag',
+ );
+ expect(mergeUrlParams({ w: null }, 'https://h/p?k1=v1&w=1#frag')).toBe(
+ 'https://h/p?k1=v1#frag',
+ );
});
- it('adds and updates encoded params', () => {
- expect(urlUtils.mergeUrlParams({ a: '&', q: '?' }, '?a=%23#frag')).toBe('?a=%26&q=%3F#frag');
+ it('adds and updates encoded param values', () => {
+ expect(mergeUrlParams({ foo: '&', q: '?' }, '?foo=%23#frag')).toBe('?foo=%26&q=%3F#frag');
+ expect(mergeUrlParams({ foo: 'a value' }, '')).toBe('?foo=a%20value');
+ expect(mergeUrlParams({ foo: 'a value' }, '?foo=1')).toBe('?foo=a%20value');
+ });
+
+ it('adds and updates encoded param names', () => {
+ expect(mergeUrlParams({ 'a name': 1 }, '')).toBe('?a%20name=1');
+ expect(mergeUrlParams({ 'a name': 2 }, '?a%20name=1')).toBe('?a%20name=2');
+ expect(mergeUrlParams({ 'a name': null }, '?a%20name=1')).toBe('');
});
it('treats "+" as "%20"', () => {
- expect(urlUtils.mergeUrlParams({ ref: 'bogus' }, '?a=lorem+ipsum&ref=charlie')).toBe(
+ expect(mergeUrlParams({ ref: 'bogus' }, '?a=lorem+ipsum&ref=charlie')).toBe(
'?a=lorem%20ipsum&ref=bogus',
);
});
+
+ it('treats question marks and slashes as part of the query', () => {
+ expect(mergeUrlParams({ ending: '!' }, '?ending=?&foo=bar')).toBe('?ending=!&foo=bar');
+ expect(mergeUrlParams({ ending: '!' }, 'https://host/path?ending=?&foo=bar')).toBe(
+ 'https://host/path?ending=!&foo=bar',
+ );
+ expect(mergeUrlParams({ ending: '?' }, '?ending=!&foo=bar')).toBe('?ending=%3F&foo=bar');
+ expect(mergeUrlParams({ ending: '?' }, 'https://host/path?ending=!&foo=bar')).toBe(
+ 'https://host/path?ending=%3F&foo=bar',
+ );
+ expect(mergeUrlParams({ ending: '!', op: '+' }, '?ending=?&op=/')).toBe('?ending=!&op=%2B');
+ expect(mergeUrlParams({ ending: '!', op: '+' }, 'https://host/path?ending=?&op=/')).toBe(
+ 'https://host/path?ending=!&op=%2B',
+ );
+ expect(mergeUrlParams({ op: '+' }, '?op=/&foo=bar')).toBe('?op=%2B&foo=bar');
+ expect(mergeUrlParams({ op: '+' }, 'https://host/path?op=/&foo=bar')).toBe(
+ 'https://host/path?op=%2B&foo=bar',
+ );
+ });
});
describe('removeParams', () => {
@@ -284,20 +323,76 @@ describe('URL utility', () => {
});
});
- describe('isAbsoluteOrRootRelative', () => {
- const validUrls = ['https://gitlab.com/', 'http://gitlab.com/', '/users/sign_in'];
-
- const invalidUrls = [' https://gitlab.com/', './file/path', 'notanurl', '<a></a>'];
+ describe('isAbsolute', () => {
+ it.each`
+ url | valid
+ ${'https://gitlab.com/'} | ${true}
+ ${'http://gitlab.com/'} | ${true}
+ ${'/users/sign_in'} | ${false}
+ ${' https://gitlab.com'} | ${false}
+ ${'somepath.php?url=https://gitlab.com'} | ${false}
+ ${'notaurl'} | ${false}
+ ${'../relative_url'} | ${false}
+ ${'<a></a>'} | ${false}
+ `('returns $valid for $url', ({ url, valid }) => {
+ expect(urlUtils.isAbsolute(url)).toBe(valid);
+ });
+ });
- it.each(validUrls)(`returns true for %s`, url => {
- expect(urlUtils.isAbsoluteOrRootRelative(url)).toBe(true);
+ describe('isRootRelative', () => {
+ it.each`
+ url | valid
+ ${'https://gitlab.com/'} | ${false}
+ ${'http://gitlab.com/'} | ${false}
+ ${'/users/sign_in'} | ${true}
+ ${' https://gitlab.com'} | ${false}
+ ${'/somepath.php?url=https://gitlab.com'} | ${true}
+ ${'notaurl'} | ${false}
+ ${'../relative_url'} | ${false}
+ ${'<a></a>'} | ${false}
+ `('returns $valid for $url', ({ url, valid }) => {
+ expect(urlUtils.isRootRelative(url)).toBe(valid);
});
+ });
- it.each(invalidUrls)(`returns false for %s`, url => {
- expect(urlUtils.isAbsoluteOrRootRelative(url)).toBe(false);
+ describe('isAbsoluteOrRootRelative', () => {
+ it.each`
+ url | valid
+ ${'https://gitlab.com/'} | ${true}
+ ${'http://gitlab.com/'} | ${true}
+ ${'/users/sign_in'} | ${true}
+ ${' https://gitlab.com'} | ${false}
+ ${'/somepath.php?url=https://gitlab.com'} | ${true}
+ ${'notaurl'} | ${false}
+ ${'../relative_url'} | ${false}
+ ${'<a></a>'} | ${false}
+ `('returns $valid for $url', ({ url, valid }) => {
+ expect(urlUtils.isAbsoluteOrRootRelative(url)).toBe(valid);
});
});
+ describe('relativePathToAbsolute', () => {
+ it.each`
+ path | base | result
+ ${'./foo'} | ${'bar/'} | ${'/bar/foo'}
+ ${'../john.md'} | ${'bar/baz/foo.php'} | ${'/bar/john.md'}
+ ${'../images/img.png'} | ${'bar/baz/foo.php'} | ${'/bar/images/img.png'}
+ ${'../images/Image 1.png'} | ${'bar/baz/foo.php'} | ${'/bar/images/Image 1.png'}
+ ${'/images/img.png'} | ${'bar/baz/foo.php'} | ${'/images/img.png'}
+ ${'/images/img.png'} | ${'/bar/baz/foo.php'} | ${'/images/img.png'}
+ ${'../john.md'} | ${'/bar/baz/foo.php'} | ${'/bar/john.md'}
+ ${'../john.md'} | ${'///bar/baz/foo.php'} | ${'/bar/john.md'}
+ ${'/images/img.png'} | ${'https://gitlab.com/user/project/'} | ${'https://gitlab.com/images/img.png'}
+ ${'../images/img.png'} | ${'https://gitlab.com/user/project/'} | ${'https://gitlab.com/user/images/img.png'}
+ ${'../images/Image 1.png'} | ${'https://gitlab.com/user/project/'} | ${'https://gitlab.com/user/images/Image%201.png'}
+ `(
+ 'converts relative path "$path" with base "$base" to absolute path => "expected"',
+ ({ path, base, result }) => {
+ expect(urlUtils.relativePathToAbsolute(path, base)).toBe(result);
+ },
+ );
+ });
+
describe('isSafeUrl', () => {
const absoluteUrls = [
'http://example.org',
@@ -386,6 +481,12 @@ describe('URL utility', () => {
expect(urlUtils.queryToObject(searchQuery)).toEqual({ one: '1', two: '2' });
});
+
+ it('removes undefined values from the search query', () => {
+ const searchQuery = '?one=1&two=2&three';
+
+ expect(urlUtils.queryToObject(searchQuery)).toEqual({ one: '1', two: '2' });
+ });
});
describe('objectToQuery', () => {
diff --git a/spec/frontend/milestones/mock_data.js b/spec/frontend/milestones/mock_data.js
new file mode 100644
index 00000000000..c64eeeba663
--- /dev/null
+++ b/spec/frontend/milestones/mock_data.js
@@ -0,0 +1,82 @@
+export const milestones = [
+ {
+ id: 41,
+ iid: 6,
+ project_id: 8,
+ title: 'v0.1',
+ description: '',
+ state: 'active',
+ created_at: '2020-04-04T01:30:40.051Z',
+ updated_at: '2020-04-04T01:30:40.051Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6',
+ },
+ {
+ id: 40,
+ iid: 5,
+ project_id: 8,
+ title: 'v4.0',
+ description: 'Laboriosam nisi sapiente dolores et magnam nobis ad earum.',
+ state: 'closed',
+ created_at: '2020-01-13T19:39:15.191Z',
+ updated_at: '2020-01-13T19:39:15.191Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/5',
+ },
+ {
+ id: 39,
+ iid: 4,
+ project_id: 8,
+ title: 'v3.0',
+ description: 'Necessitatibus illo alias et repellat dolorum assumenda ut.',
+ state: 'closed',
+ created_at: '2020-01-13T19:39:15.176Z',
+ updated_at: '2020-01-13T19:39:15.176Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/4',
+ },
+ {
+ id: 38,
+ iid: 3,
+ project_id: 8,
+ title: 'v2.0',
+ description: 'Doloribus qui repudiandae iste sit.',
+ state: 'closed',
+ created_at: '2020-01-13T19:39:15.161Z',
+ updated_at: '2020-01-13T19:39:15.161Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/3',
+ },
+ {
+ id: 37,
+ iid: 2,
+ project_id: 8,
+ title: 'v1.0',
+ description: 'Illo sint odio officia ea.',
+ state: 'closed',
+ created_at: '2020-01-13T19:39:15.146Z',
+ updated_at: '2020-01-13T19:39:15.146Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/2',
+ },
+ {
+ id: 36,
+ iid: 1,
+ project_id: 8,
+ title: 'v0.0',
+ description: 'Sed quae facilis deleniti at delectus assumenda nobis veritatis.',
+ state: 'active',
+ created_at: '2020-01-13T19:39:15.127Z',
+ updated_at: '2020-01-13T19:39:15.127Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/1',
+ },
+];
+
+export default milestones;
diff --git a/spec/frontend/milestones/project_milestone_combobox_spec.js b/spec/frontend/milestones/project_milestone_combobox_spec.js
new file mode 100644
index 00000000000..a7321d21559
--- /dev/null
+++ b/spec/frontend/milestones/project_milestone_combobox_spec.js
@@ -0,0 +1,150 @@
+import { milestones as projectMilestones } from './mock_data';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { shallowMount } from '@vue/test-utils';
+import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
+import { GlNewDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
+
+const TEST_SEARCH_ENDPOINT = '/api/v4/projects/8/search';
+
+const extraLinks = [
+ { text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' },
+ { text: 'Manage milestones', url: '/h5bp/html5-boilerplate/-/milestones' },
+];
+
+const preselectedMilestones = [];
+const projectId = '8';
+
+describe('Milestone selector', () => {
+ let wrapper;
+ let mock;
+
+ const findNoResultsMessage = () => wrapper.find({ ref: 'noResults' });
+
+ const factory = (options = {}) => {
+ wrapper = shallowMount(MilestoneCombobox, {
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ gon.api_version = 'v4';
+
+ mock.onGet('/api/v4/projects/8/milestones').reply(200, projectMilestones);
+
+ factory({
+ propsData: {
+ projectId,
+ preselectedMilestones,
+ extraLinks,
+ },
+ });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders the dropdown', () => {
+ expect(wrapper.find(GlNewDropdown)).toExist();
+ });
+
+ it('renders additional links', () => {
+ const links = wrapper.findAll('[href]');
+ links.wrappers.forEach((item, idx) => {
+ expect(item.text()).toBe(extraLinks[idx].text);
+ expect(item.attributes('href')).toBe(extraLinks[idx].url);
+ });
+ });
+
+ describe('before results', () => {
+ it('should show a loading icon', () => {
+ const request = mock.onGet(TEST_SEARCH_ENDPOINT, {
+ params: { search: 'TEST_SEARCH', scope: 'milestones' },
+ });
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+
+ return wrapper.vm.$nextTick().then(() => {
+ request.reply(200, []);
+ });
+ });
+
+ it('should not show any dropdown items', () => {
+ expect(wrapper.findAll('[role="milestone option"]')).toHaveLength(0);
+ });
+
+ it('should have "No milestone" as the button text', () => {
+ expect(wrapper.find({ ref: 'buttonText' }).text()).toBe('No milestone');
+ });
+ });
+
+ describe('with empty results', () => {
+ beforeEach(() => {
+ mock
+ .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'TEST_SEARCH', scope: 'milestones' } })
+ .reply(200, []);
+ wrapper.find(GlSearchBoxByType).vm.$emit('input', 'TEST_SEARCH');
+ return axios.waitForAll();
+ });
+
+ it('should display that no matching items are found', () => {
+ expect(findNoResultsMessage().exists()).toBe(true);
+ });
+ });
+
+ describe('with results', () => {
+ let items;
+ beforeEach(() => {
+ mock
+ .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'v0.1', scope: 'milestones' } })
+ .reply(200, [
+ {
+ id: 41,
+ iid: 6,
+ project_id: 8,
+ title: 'v0.1',
+ description: '',
+ state: 'active',
+ created_at: '2020-04-04T01:30:40.051Z',
+ updated_at: '2020-04-04T01:30:40.051Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6',
+ },
+ ]);
+ wrapper.find(GlSearchBoxByType).vm.$emit('input', 'v0.1');
+ return axios.waitForAll().then(() => {
+ items = wrapper.findAll('[role="milestone option"]');
+ });
+ });
+
+ it('should display one item per result', () => {
+ expect(items).toHaveLength(1);
+ });
+
+ it('should emit a change if an item is clicked', () => {
+ items.at(0).vm.$emit('click');
+ expect(wrapper.emitted().change.length).toBe(1);
+ expect(wrapper.emitted().change[0]).toEqual([[{ title: 'v0.1' }]]);
+ });
+
+ it('should not have a selecton icon on any item', () => {
+ items.wrappers.forEach(item => {
+ expect(item.find('.selected-item').exists()).toBe(false);
+ });
+ });
+
+ it('should have a selecton icon if an item is clicked', () => {
+ items.at(0).vm.$emit('click');
+ expect(wrapper.find('.selected-item').exists()).toBe(true);
+ });
+
+ it('should not display a message about no results', () => {
+ expect(findNoResultsMessage().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/mocks/ce/diffs/workers/tree_worker.js b/spec/frontend/mocks/ce/diffs/workers/tree_worker.js
index a33ddbbfe63..5532a22f8e6 100644
--- a/spec/frontend/mocks/ce/diffs/workers/tree_worker.js
+++ b/spec/frontend/mocks/ce/diffs/workers/tree_worker.js
@@ -1,8 +1 @@
-/* eslint-disable class-methods-use-this */
-export default class TreeWorkerMock {
- addEventListener() {}
-
- terminate() {}
-
- postMessage() {}
-}
+export { default } from 'helpers/web_worker_mock';
diff --git a/spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js b/spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js
new file mode 100644
index 00000000000..5532a22f8e6
--- /dev/null
+++ b/spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js
@@ -0,0 +1 @@
+export { default } from 'helpers/web_worker_mock';
diff --git a/spec/frontend/mocks_spec.js b/spec/frontend/mocks_spec.js
index a4a1fdea396..110c418e579 100644
--- a/spec/frontend/mocks_spec.js
+++ b/spec/frontend/mocks_spec.js
@@ -8,12 +8,13 @@ describe('Mock auto-injection', () => {
failMock = jest.spyOn(global, 'fail').mockImplementation();
});
- it('~/lib/utils/axios_utils', done => {
- expect(axios.get('http://gitlab.com')).rejects.toThrow('Unexpected unmocked request');
- setImmediate(() => {
- expect(failMock).toHaveBeenCalledTimes(1);
- done();
- });
+ it('~/lib/utils/axios_utils', () => {
+ return Promise.all([
+ expect(axios.get('http://gitlab.com')).rejects.toThrow('Unexpected unmocked request'),
+ setImmediate(() => {
+ expect(failMock).toHaveBeenCalledTimes(1);
+ }),
+ ]);
});
it('jQuery.ajax()', () => {
diff --git a/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap b/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap
new file mode 100644
index 00000000000..2179e7b4ab5
--- /dev/null
+++ b/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap
@@ -0,0 +1,43 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AlertWidget Alert firing displays a warning icon and matches snapshot 1`] = `
+<gl-badge-stub
+ class="d-flex-center text-truncate"
+ pill=""
+ variant="danger"
+>
+ <gl-icon-stub
+ class="flex-shrink-0"
+ name="warning"
+ size="16"
+ />
+
+ <span
+ class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me"
+ >
+ Firing:
+ alert-label &gt; 42
+
+ </span>
+</gl-badge-stub>
+`;
+
+exports[`AlertWidget Alert not firing displays a warning icon and matches snapshot 1`] = `
+<gl-badge-stub
+ class="d-flex-center text-truncate"
+ pill=""
+ variant="secondary"
+>
+ <gl-icon-stub
+ class="flex-shrink-0"
+ name="warning"
+ size="16"
+ />
+
+ <span
+ class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me"
+ >
+ alert-label &gt; 42
+ </span>
+</gl-badge-stub>
+`;
diff --git a/spec/frontend/monitoring/alert_widget_spec.js b/spec/frontend/monitoring/alert_widget_spec.js
new file mode 100644
index 00000000000..f0355dfa01b
--- /dev/null
+++ b/spec/frontend/monitoring/alert_widget_spec.js
@@ -0,0 +1,422 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon, GlTooltip, GlSprintf, GlBadge } from '@gitlab/ui';
+import AlertWidget from '~/monitoring/components/alert_widget.vue';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+
+const mockReadAlert = jest.fn();
+const mockCreateAlert = jest.fn();
+const mockUpdateAlert = jest.fn();
+const mockDeleteAlert = jest.fn();
+
+jest.mock('~/flash');
+jest.mock(
+ '~/monitoring/services/alerts_service',
+ () =>
+ function AlertsServiceMock() {
+ return {
+ readAlert: mockReadAlert,
+ createAlert: mockCreateAlert,
+ updateAlert: mockUpdateAlert,
+ deleteAlert: mockDeleteAlert,
+ };
+ },
+);
+
+describe('AlertWidget', () => {
+ let wrapper;
+
+ const nonFiringAlertResult = [
+ {
+ values: [[0, 1], [1, 42], [2, 41]],
+ },
+ ];
+ const firingAlertResult = [
+ {
+ values: [[0, 42], [1, 43], [2, 44]],
+ },
+ ];
+ const metricId = '5';
+ const alertPath = 'my/alert.json';
+
+ const relevantQueries = [
+ {
+ metricId,
+ label: 'alert-label',
+ alert_path: alertPath,
+ result: nonFiringAlertResult,
+ },
+ ];
+
+ const firingRelevantQueries = [
+ {
+ metricId,
+ label: 'alert-label',
+ alert_path: alertPath,
+ result: firingAlertResult,
+ },
+ ];
+
+ const defaultProps = {
+ alertsEndpoint: '',
+ relevantQueries,
+ alertsToManage: {},
+ modalId: 'alert-modal-1',
+ };
+
+ const propsWithAlert = {
+ relevantQueries,
+ };
+
+ const propsWithAlertData = {
+ relevantQueries,
+ alertsToManage: {
+ [alertPath]: { operator: '>', threshold: 42, alert_path: alertPath, metricId },
+ },
+ };
+
+ const createComponent = propsData => {
+ wrapper = shallowMount(AlertWidget, {
+ stubs: { GlTooltip, GlSprintf },
+ propsData: {
+ ...defaultProps,
+ ...propsData,
+ },
+ });
+ };
+ const hasLoadingIcon = () => wrapper.contains(GlLoadingIcon);
+ const findWidgetForm = () => wrapper.find({ ref: 'widgetForm' });
+ const findAlertErrorMessage = () => wrapper.find({ ref: 'alertErrorMessage' });
+ const findCurrentSettingsText = () =>
+ wrapper
+ .find({ ref: 'alertCurrentSetting' })
+ .text()
+ .replace(/\s\s+/g, ' ');
+ const findBadge = () => wrapper.find(GlBadge);
+ const findTooltip = () => wrapper.find(GlTooltip);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('displays a loading spinner and disables form when fetching alerts', () => {
+ let resolveReadAlert;
+ mockReadAlert.mockReturnValue(
+ new Promise(resolve => {
+ resolveReadAlert = resolve;
+ }),
+ );
+ createComponent(defaultProps);
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(hasLoadingIcon()).toBe(true);
+ expect(findWidgetForm().props('disabled')).toBe(true);
+
+ resolveReadAlert({ operator: '==', threshold: 42 });
+ })
+ .then(() => waitForPromises())
+ .then(() => {
+ expect(hasLoadingIcon()).toBe(false);
+ expect(findWidgetForm().props('disabled')).toBe(false);
+ });
+ });
+
+ it('does not render loading spinner if showLoadingState is false', () => {
+ let resolveReadAlert;
+ mockReadAlert.mockReturnValue(
+ new Promise(resolve => {
+ resolveReadAlert = resolve;
+ }),
+ );
+ createComponent({
+ ...defaultProps,
+ showLoadingState: false,
+ });
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+
+ resolveReadAlert({ operator: '==', threshold: 42 });
+ })
+ .then(() => waitForPromises())
+ .then(() => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ });
+ });
+
+ it('displays an error message when fetch fails', () => {
+ mockReadAlert.mockRejectedValue();
+ createComponent(propsWithAlert);
+ expect(hasLoadingIcon()).toBe(true);
+
+ return waitForPromises().then(() => {
+ expect(createFlash).toHaveBeenCalled();
+ expect(hasLoadingIcon()).toBe(false);
+ });
+ });
+
+ describe('Alert not firing', () => {
+ it('displays a warning icon and matches snapshot', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ createComponent(propsWithAlertData);
+
+ return waitForPromises().then(() => {
+ expect(findBadge().element).toMatchSnapshot();
+ });
+ });
+
+ it('displays an alert summary when there is a single alert', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ createComponent(propsWithAlertData);
+ return waitForPromises().then(() => {
+ expect(findCurrentSettingsText()).toEqual('alert-label > 42');
+ });
+ });
+
+ it('displays a combined alert summary when there are multiple alerts', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ const propsWithManyAlerts = {
+ relevantQueries: [
+ ...relevantQueries,
+ ...[
+ {
+ metricId: '6',
+ alert_path: 'my/alert2.json',
+ label: 'alert-label2',
+ result: [{ values: [] }],
+ },
+ ],
+ ],
+ alertsToManage: {
+ 'my/alert.json': {
+ operator: '>',
+ threshold: 42,
+ alert_path: alertPath,
+ metricId,
+ },
+ 'my/alert2.json': {
+ operator: '==',
+ threshold: 900,
+ alert_path: 'my/alert2.json',
+ metricId: '6',
+ },
+ },
+ };
+ createComponent(propsWithManyAlerts);
+ return waitForPromises().then(() => {
+ expect(findCurrentSettingsText()).toContain('2 alerts applied');
+ });
+ });
+ });
+
+ describe('Alert firing', () => {
+ it('displays a warning icon and matches snapshot', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ propsWithAlertData.relevantQueries = firingRelevantQueries;
+ createComponent(propsWithAlertData);
+
+ return waitForPromises().then(() => {
+ expect(findBadge().element).toMatchSnapshot();
+ });
+ });
+
+ it('displays an alert summary when there is a single alert', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ propsWithAlertData.relevantQueries = firingRelevantQueries;
+ createComponent(propsWithAlertData);
+ return waitForPromises().then(() => {
+ expect(findCurrentSettingsText()).toEqual('Firing: alert-label > 42');
+ });
+ });
+
+ it('displays a combined alert summary when there are multiple alerts', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ const propsWithManyAlerts = {
+ relevantQueries: [
+ ...firingRelevantQueries,
+ ...[
+ {
+ metricId: '6',
+ alert_path: 'my/alert2.json',
+ label: 'alert-label2',
+ result: [{ values: [] }],
+ },
+ ],
+ ],
+ alertsToManage: {
+ 'my/alert.json': {
+ operator: '>',
+ threshold: 42,
+ alert_path: alertPath,
+ metricId,
+ },
+ 'my/alert2.json': {
+ operator: '==',
+ threshold: 900,
+ alert_path: 'my/alert2.json',
+ metricId: '6',
+ },
+ },
+ };
+ createComponent(propsWithManyAlerts);
+
+ return waitForPromises().then(() => {
+ expect(findCurrentSettingsText()).toContain('2 alerts applied, 1 firing');
+ });
+ });
+
+ it('should display tooltip with thresholds summary', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ const propsWithManyAlerts = {
+ relevantQueries: [
+ ...firingRelevantQueries,
+ ...[
+ {
+ metricId: '6',
+ alert_path: 'my/alert2.json',
+ label: 'alert-label2',
+ result: [{ values: [] }],
+ },
+ ],
+ ],
+ alertsToManage: {
+ 'my/alert.json': {
+ operator: '>',
+ threshold: 42,
+ alert_path: alertPath,
+ metricId,
+ },
+ 'my/alert2.json': {
+ operator: '==',
+ threshold: 900,
+ alert_path: 'my/alert2.json',
+ metricId: '6',
+ },
+ },
+ };
+ createComponent(propsWithManyAlerts);
+
+ return waitForPromises().then(() => {
+ expect(
+ findTooltip()
+ .text()
+ .replace(/\s\s+/g, ' '),
+ ).toEqual('Firing: alert-label > 42');
+ });
+ });
+ });
+
+ it('creates an alert with an appropriate handler', () => {
+ const alertParams = {
+ operator: '<',
+ threshold: 4,
+ prometheus_metric_id: '5',
+ };
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ const fakeAlertPath = 'foo/bar';
+ mockCreateAlert.mockResolvedValue({ alert_path: fakeAlertPath, ...alertParams });
+ createComponent({
+ alertsToManage: {
+ [fakeAlertPath]: {
+ alert_path: fakeAlertPath,
+ operator: '<',
+ threshold: 4,
+ prometheus_metric_id: '5',
+ metricId: '5',
+ },
+ },
+ });
+
+ findWidgetForm().vm.$emit('create', alertParams);
+
+ expect(mockCreateAlert).toHaveBeenCalledWith(alertParams);
+ });
+
+ it('updates an alert with an appropriate handler', () => {
+ const alertParams = { operator: '<', threshold: 4, alert_path: alertPath };
+ const newAlertParams = { operator: '==', threshold: 12 };
+ mockReadAlert.mockResolvedValue(alertParams);
+ mockUpdateAlert.mockResolvedValue({ ...alertParams, ...newAlertParams });
+ createComponent({
+ ...propsWithAlertData,
+ alertsToManage: {
+ [alertPath]: {
+ alert_path: alertPath,
+ operator: '==',
+ threshold: 12,
+ metricId: '5',
+ },
+ },
+ });
+
+ findWidgetForm().vm.$emit('update', {
+ alert: alertPath,
+ ...newAlertParams,
+ prometheus_metric_id: '5',
+ });
+
+ expect(mockUpdateAlert).toHaveBeenCalledWith(alertPath, newAlertParams);
+ });
+
+ it('deletes an alert with an appropriate handler', () => {
+ const alertParams = { alert_path: alertPath, operator: '>', threshold: 42 };
+ mockReadAlert.mockResolvedValue(alertParams);
+ mockDeleteAlert.mockResolvedValue({});
+ createComponent({
+ ...propsWithAlert,
+ alertsToManage: {
+ [alertPath]: {
+ alert_path: alertPath,
+ operator: '>',
+ threshold: 42,
+ metricId: '5',
+ },
+ },
+ });
+
+ findWidgetForm().vm.$emit('delete', { alert: alertPath });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(mockDeleteAlert).toHaveBeenCalledWith(alertPath);
+ expect(findAlertErrorMessage().exists()).toBe(false);
+ });
+ });
+
+ describe('when delete fails', () => {
+ beforeEach(() => {
+ const alertParams = { alert_path: alertPath, operator: '>', threshold: 42 };
+ mockReadAlert.mockResolvedValue(alertParams);
+ mockDeleteAlert.mockRejectedValue();
+
+ createComponent({
+ ...propsWithAlert,
+ alertsToManage: {
+ [alertPath]: {
+ alert_path: alertPath,
+ operator: '>',
+ threshold: 42,
+ metricId: '5',
+ },
+ },
+ });
+
+ findWidgetForm().vm.$emit('delete', { alert: alertPath });
+ return wrapper.vm.$nextTick();
+ });
+
+ it('shows error message', () => {
+ expect(findAlertErrorMessage().text()).toEqual('Error deleting alert');
+ });
+
+ it('dismisses error message on cancel', () => {
+ findWidgetForm().vm.$emit('cancel');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findAlertErrorMessage().exists()).toBe(false);
+ });
+ });
+ });
+});
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 1906ad7c6ed..9be5fa72110 100644
--- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
+++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
@@ -16,7 +16,6 @@ exports[`Dashboard template matches the default snapshot 1`] = `
data-qa-selector="dashboards_filter_dropdown"
defaultbranch="master"
id="monitor-dashboards-dropdown"
- selecteddashboard="[object Object]"
toggle-class="dropdown-menu-toggle"
/>
</div>
@@ -72,7 +71,7 @@ exports[`Dashboard template matches the default snapshot 1`] = `
<date-time-picker-stub
class="flex-grow-1 show-last-dropdown"
customenabled="true"
- data-qa-selector="show_last_dropdown"
+ data-qa-selector="range_picker_dropdown"
options="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]"
value="[object Object]"
/>
@@ -101,6 +100,26 @@ exports[`Dashboard template matches the default snapshot 1`] = `
<div
class="d-sm-flex"
>
+ <div
+ class="mb-2 mr-2 d-flex"
+ >
+ <div
+ class="flex-grow-1"
+ title="Star dashboard"
+ >
+ <gl-deprecated-button-stub
+ class="w-100"
+ size="md"
+ variant="default"
+ >
+ <gl-icon-stub
+ name="star-o"
+ size="16"
+ />
+ </gl-deprecated-button-stub>
+ </div>
+ </div>
+
<!---->
<!---->
@@ -111,6 +130,8 @@ exports[`Dashboard template matches the default snapshot 1`] = `
</div>
</div>
+ <!---->
+
<empty-state-stub
clusterspath="/path/to/clusters"
documentationpath="/path/to/docs"
diff --git a/spec/frontend/monitoring/components/alert_widget_form_spec.js b/spec/frontend/monitoring/components/alert_widget_form_spec.js
new file mode 100644
index 00000000000..a8416216a94
--- /dev/null
+++ b/spec/frontend/monitoring/components/alert_widget_form_spec.js
@@ -0,0 +1,220 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLink } from '@gitlab/ui';
+import AlertWidgetForm from '~/monitoring/components/alert_widget_form.vue';
+import ModalStub from '../stubs/modal_stub';
+
+describe('AlertWidgetForm', () => {
+ let wrapper;
+
+ const metricId = '8';
+ const alertPath = 'alert';
+ const relevantQueries = [{ metricId, alert_path: alertPath, label: 'alert-label' }];
+ const dataTrackingOptions = {
+ create: { action: 'click_button', label: 'create_alert' },
+ delete: { action: 'click_button', label: 'delete_alert' },
+ update: { action: 'click_button', label: 'update_alert' },
+ };
+
+ const defaultProps = {
+ disabled: false,
+ relevantQueries,
+ modalId: 'alert-modal-1',
+ };
+
+ const propsWithAlertData = {
+ ...defaultProps,
+ alertsToManage: {
+ alert: { alert_path: alertPath, operator: '<', threshold: 5, metricId },
+ },
+ configuredAlert: metricId,
+ };
+
+ function createComponent(props = {}) {
+ const propsData = {
+ ...defaultProps,
+ ...props,
+ };
+
+ wrapper = shallowMount(AlertWidgetForm, {
+ propsData,
+ stubs: {
+ GlModal: ModalStub,
+ },
+ });
+ }
+
+ const modal = () => wrapper.find(ModalStub);
+ const modalTitle = () => modal().attributes('title');
+ const submitButton = () => modal().find(GlLink);
+ const submitButtonTrackingOpts = () =>
+ JSON.parse(submitButton().attributes('data-tracking-options'));
+ const e = {
+ preventDefault: jest.fn(),
+ };
+
+ beforeEach(() => {
+ e.preventDefault.mockReset();
+ });
+
+ afterEach(() => {
+ if (wrapper) wrapper.destroy();
+ });
+
+ it('disables the form when disabled prop is set', () => {
+ createComponent({ disabled: true });
+
+ expect(modal().attributes('ok-disabled')).toBe('true');
+ });
+
+ it('disables the form if no query is selected', () => {
+ createComponent();
+
+ expect(modal().attributes('ok-disabled')).toBe('true');
+ });
+
+ it('shows correct title and button text', () => {
+ expect(modalTitle()).toBe('Add alert');
+ expect(submitButton().text()).toBe('Add');
+ });
+
+ it('sets tracking options for create alert', () => {
+ expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.create);
+ });
+
+ it('emits a "create" event when form submitted without existing alert', () => {
+ createComponent();
+
+ wrapper.vm.selectQuery('9');
+ wrapper.setData({
+ threshold: 900,
+ });
+
+ wrapper.vm.handleSubmit(e);
+
+ expect(wrapper.emitted().create[0]).toEqual([
+ {
+ alert: undefined,
+ operator: '>',
+ threshold: 900,
+ prometheus_metric_id: '9',
+ },
+ ]);
+ expect(e.preventDefault).toHaveBeenCalledTimes(1);
+ });
+
+ it('resets form when modal is dismissed (hidden)', () => {
+ createComponent();
+
+ wrapper.vm.selectQuery('9');
+ wrapper.vm.selectQuery('>');
+ wrapper.setData({
+ threshold: 800,
+ });
+
+ modal().vm.$emit('hidden');
+
+ expect(wrapper.vm.selectedAlert).toEqual({});
+ expect(wrapper.vm.operator).toBe(null);
+ expect(wrapper.vm.threshold).toBe(null);
+ expect(wrapper.vm.prometheusMetricId).toBe(null);
+ });
+
+ it('sets selectedAlert to the provided configuredAlert on modal show', () => {
+ createComponent(propsWithAlertData);
+
+ modal().vm.$emit('shown');
+
+ expect(wrapper.vm.selectedAlert).toEqual(propsWithAlertData.alertsToManage[alertPath]);
+ });
+
+ it('sets selectedAlert to the first relevantQueries if there is only one option on modal show', () => {
+ createComponent({
+ ...propsWithAlertData,
+ configuredAlert: '',
+ });
+
+ modal().vm.$emit('shown');
+
+ expect(wrapper.vm.selectedAlert).toEqual(propsWithAlertData.alertsToManage[alertPath]);
+ });
+
+ it('does not set selectedAlert to the first relevantQueries if there is more than one option on modal show', () => {
+ createComponent({
+ relevantQueries: [
+ {
+ metricId: '8',
+ alertPath: 'alert',
+ label: 'alert-label',
+ },
+ {
+ metricId: '9',
+ alertPath: 'alert',
+ label: 'alert-label',
+ },
+ ],
+ });
+
+ modal().vm.$emit('shown');
+
+ expect(wrapper.vm.selectedAlert).toEqual({});
+ });
+
+ describe('with existing alert', () => {
+ beforeEach(() => {
+ createComponent(propsWithAlertData);
+
+ wrapper.vm.selectQuery(metricId);
+ });
+
+ it('sets tracking options for delete alert', () => {
+ expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.delete);
+ });
+
+ it('updates button text', () => {
+ expect(modalTitle()).toBe('Edit alert');
+ expect(submitButton().text()).toBe('Delete');
+ });
+
+ it('emits "delete" event when form values unchanged', () => {
+ wrapper.vm.handleSubmit(e);
+
+ expect(wrapper.emitted().delete[0]).toEqual([
+ {
+ alert: 'alert',
+ operator: '<',
+ threshold: 5,
+ prometheus_metric_id: '8',
+ },
+ ]);
+ expect(e.preventDefault).toHaveBeenCalledTimes(1);
+ });
+
+ it('emits "update" event when form changed', () => {
+ wrapper.setData({
+ threshold: 11,
+ });
+
+ wrapper.vm.handleSubmit(e);
+
+ expect(wrapper.emitted().update[0]).toEqual([
+ {
+ alert: 'alert',
+ operator: '<',
+ threshold: 11,
+ prometheus_metric_id: '8',
+ },
+ ]);
+ expect(e.preventDefault).toHaveBeenCalledTimes(1);
+ });
+
+ it('sets tracking options for update alert', () => {
+ wrapper.setData({
+ threshold: 11,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.update);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/components/charts/single_stat_spec.js b/spec/frontend/monitoring/components/charts/single_stat_spec.js
index fb0682d0338..9cc5970da82 100644
--- a/spec/frontend/monitoring/components/charts/single_stat_spec.js
+++ b/spec/frontend/monitoring/components/charts/single_stat_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import SingleStatChart from '~/monitoring/components/charts/single_stat.vue';
-import { graphDataPrometheusQuery } from '../../mock_data';
+import { singleStatMetricsResult } from '../../mock_data';
describe('Single Stat Chart component', () => {
let singleStatChart;
@@ -8,7 +8,7 @@ describe('Single Stat Chart component', () => {
beforeEach(() => {
singleStatChart = shallowMount(SingleStatChart, {
propsData: {
- graphData: graphDataPrometheusQuery,
+ graphData: singleStatMetricsResult,
},
});
});
@@ -26,7 +26,7 @@ describe('Single Stat Chart component', () => {
it('should change the value representation to a percentile one', () => {
singleStatChart.setProps({
graphData: {
- ...graphDataPrometheusQuery,
+ ...singleStatMetricsResult,
maxValue: 120,
},
});
@@ -37,7 +37,7 @@ describe('Single Stat Chart component', () => {
it('should display NaN for non numeric maxValue values', () => {
singleStatChart.setProps({
graphData: {
- ...graphDataPrometheusQuery,
+ ...singleStatMetricsResult,
maxValue: 'not a number',
},
});
@@ -48,13 +48,13 @@ describe('Single Stat Chart component', () => {
it('should display NaN for missing query values', () => {
singleStatChart.setProps({
graphData: {
- ...graphDataPrometheusQuery,
+ ...singleStatMetricsResult,
metrics: [
{
- ...graphDataPrometheusQuery.metrics[0],
+ ...singleStatMetricsResult.metrics[0],
result: [
{
- ...graphDataPrometheusQuery.metrics[0].result[0],
+ ...singleStatMetricsResult.metrics[0].result[0],
value: [''],
},
],
diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js
index 5ac716b0c63..7d5a08bc4a1 100644
--- a/spec/frontend/monitoring/components/charts/time_series_spec.js
+++ b/spec/frontend/monitoring/components/charts/time_series_spec.js
@@ -1,4 +1,4 @@
-import { mount } from '@vue/test-utils';
+import { mount, shallowMount } from '@vue/test-utils';
import { setTestTimeout } from 'helpers/timeout';
import { GlLink } from '@gitlab/ui';
import { TEST_HOST } from 'jest/helpers/test_constants';
@@ -11,6 +11,7 @@ import {
import { cloneDeep } from 'lodash';
import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper';
import { createStore } from '~/monitoring/stores';
+import { panelTypes, chartHeight } from '~/monitoring/constants';
import TimeSeries from '~/monitoring/components/charts/time_series.vue';
import * as types from '~/monitoring/stores/mutation_types';
import { deploymentData, mockProjectDir, annotationsData } from '../../mock_data';
@@ -39,10 +40,10 @@ describe('Time series component', () => {
let mockGraphData;
let store;
- const makeTimeSeriesChart = (graphData, type) =>
- mount(TimeSeries, {
+ const createWrapper = (graphData = mockGraphData, mountingMethod = shallowMount) =>
+ mountingMethod(TimeSeries, {
propsData: {
- graphData: { ...graphData, type },
+ graphData,
deploymentData: store.state.monitoringDashboard.deploymentData,
annotations: store.state.monitoringDashboard.annotations,
projectPath: `${TEST_HOST}${mockProjectDir}`,
@@ -79,9 +80,9 @@ describe('Time series component', () => {
const findChart = () => timeSeriesChart.find({ ref: 'chart' });
- beforeEach(done => {
- timeSeriesChart = makeTimeSeriesChart(mockGraphData, 'area-chart');
- timeSeriesChart.vm.$nextTick(done);
+ beforeEach(() => {
+ timeSeriesChart = createWrapper(mockGraphData, mount);
+ return timeSeriesChart.vm.$nextTick();
});
it('allows user to override max value label text using prop', () => {
@@ -100,6 +101,21 @@ describe('Time series component', () => {
});
});
+ it('chart sets a default height', () => {
+ const wrapper = createWrapper();
+ expect(wrapper.props('height')).toBe(chartHeight);
+ });
+
+ it('chart has a configurable height', () => {
+ const mockHeight = 599;
+ const wrapper = createWrapper();
+
+ wrapper.setProps({ height: mockHeight });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.props('height')).toBe(mockHeight);
+ });
+ });
+
describe('events', () => {
describe('datazoom', () => {
let eChartMock;
@@ -125,7 +141,7 @@ describe('Time series component', () => {
}),
};
- timeSeriesChart = makeTimeSeriesChart(mockGraphData);
+ timeSeriesChart = createWrapper(mockGraphData, mount);
timeSeriesChart.vm.$nextTick(() => {
findChart().vm.$emit('created', eChartMock);
done();
@@ -535,11 +551,11 @@ describe('Time series component', () => {
describe('wrapped components', () => {
const glChartComponents = [
{
- chartType: 'area-chart',
+ chartType: panelTypes.AREA_CHART,
component: GlAreaChart,
},
{
- chartType: 'line-chart',
+ chartType: panelTypes.LINE_CHART,
component: GlLineChart,
},
];
@@ -550,7 +566,10 @@ describe('Time series component', () => {
const findChartComponent = () => timeSeriesAreaChart.find(dynamicComponent.component);
beforeEach(done => {
- timeSeriesAreaChart = makeTimeSeriesChart(mockGraphData, dynamicComponent.chartType);
+ timeSeriesAreaChart = createWrapper(
+ { ...mockGraphData, type: dynamicComponent.chartType },
+ mount,
+ );
timeSeriesAreaChart.vm.$nextTick(done);
});
@@ -632,7 +651,7 @@ describe('Time series component', () => {
Object.assign(metric, { result: metricResultStatus.result }),
);
- timeSeriesChart = makeTimeSeriesChart(graphData, 'area-chart');
+ timeSeriesChart = createWrapper({ ...graphData, type: 'area-chart' }, mount);
timeSeriesChart.vm.$nextTick(done);
});
diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js
new file mode 100644
index 00000000000..f8c9bd56721
--- /dev/null
+++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js
@@ -0,0 +1,576 @@
+import Vuex from 'vuex';
+import { shallowMount } from '@vue/test-utils';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import { setTestTimeout } from 'helpers/timeout';
+import invalidUrl from '~/lib/utils/invalid_url';
+import axios from '~/lib/utils/axios_utils';
+import { GlDropdownItem } from '@gitlab/ui';
+import AlertWidget from '~/monitoring/components/alert_widget.vue';
+
+import DashboardPanel from '~/monitoring/components/dashboard_panel.vue';
+import {
+ anomalyMockGraphData,
+ mockLogsHref,
+ mockLogsPath,
+ mockNamespace,
+ mockNamespacedData,
+ mockTimeRange,
+ singleStatMetricsResult,
+ graphDataPrometheusQueryRangeMultiTrack,
+ barMockData,
+ propsData,
+} from '../mock_data';
+
+import { panelTypes } from '~/monitoring/constants';
+
+import MonitorEmptyChart from '~/monitoring/components/charts/empty_chart.vue';
+import MonitorTimeSeriesChart from '~/monitoring/components/charts/time_series.vue';
+import MonitorAnomalyChart from '~/monitoring/components/charts/anomaly.vue';
+import MonitorSingleStatChart from '~/monitoring/components/charts/single_stat.vue';
+import MonitorHeatmapChart from '~/monitoring/components/charts/heatmap.vue';
+import MonitorColumnChart from '~/monitoring/components/charts/column.vue';
+import MonitorBarChart from '~/monitoring/components/charts/bar.vue';
+import MonitorStackedColumnChart from '~/monitoring/components/charts/stacked_column.vue';
+
+import { graphData, graphDataEmpty } from '../fixture_data';
+import { createStore, monitoringDashboard } from '~/monitoring/stores';
+import { createStore as createEmbedGroupStore } from '~/monitoring/stores/embed_group';
+
+global.URL.createObjectURL = jest.fn();
+
+const mocks = {
+ $toast: {
+ show: jest.fn(),
+ },
+};
+
+describe('Dashboard Panel', () => {
+ let axiosMock;
+ let store;
+ let state;
+ let wrapper;
+
+ const exampleText = 'example_text';
+
+ const findCopyLink = () => wrapper.find({ ref: 'copyChartLink' });
+ const findTimeChart = () => wrapper.find({ ref: 'timeSeriesChart' });
+ const findTitle = () => wrapper.find({ ref: 'graphTitle' });
+ const findContextualMenu = () => wrapper.find({ ref: 'contextualMenu' });
+
+ const createWrapper = (props, options) => {
+ wrapper = shallowMount(DashboardPanel, {
+ propsData: {
+ graphData,
+ settingsPath: propsData.settingsPath,
+ ...props,
+ },
+ store,
+ mocks,
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ setTestTimeout(1000);
+
+ store = createStore();
+ state = store.state.monitoringDashboard;
+
+ axiosMock = new AxiosMockAdapter(axios);
+ });
+
+ afterEach(() => {
+ axiosMock.reset();
+ });
+
+ describe('Renders slots', () => {
+ it('renders "topLeft" slot', () => {
+ createWrapper(
+ {},
+ {
+ slots: {
+ topLeft: `<div class="top-left-content">OK</div>`,
+ },
+ },
+ );
+
+ expect(wrapper.find('.top-left-content').exists()).toBe(true);
+ expect(wrapper.find('.top-left-content').text()).toBe('OK');
+ });
+ });
+
+ describe('When no graphData is available', () => {
+ beforeEach(() => {
+ createWrapper({
+ graphData: graphDataEmpty,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders the chart title', () => {
+ expect(findTitle().text()).toBe(graphDataEmpty.title);
+ });
+
+ it('renders no download csv link', () => {
+ expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(false);
+ });
+
+ it('does not contain graph widgets', () => {
+ expect(findContextualMenu().exists()).toBe(false);
+ });
+
+ 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);
+ });
+ });
+
+ describe('When graphData is null', () => {
+ beforeEach(() => {
+ createWrapper({
+ graphData: null,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders no chart title', () => {
+ expect(findTitle().text()).toBe('');
+ });
+
+ it('renders no download csv link', () => {
+ expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(false);
+ });
+
+ it('does not contain graph widgets', () => {
+ expect(findContextualMenu().exists()).toBe(false);
+ });
+
+ 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);
+ });
+ });
+
+ describe('When graphData is available', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders the chart title', () => {
+ expect(findTitle().text()).toBe(graphData.title);
+ });
+
+ it('contains graph widgets', () => {
+ expect(findContextualMenu().exists()).toBe(true);
+ expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(true);
+ });
+
+ it('sets no clipboard copy link on dropdown by default', () => {
+ expect(findCopyLink().exists()).toBe(false);
+ });
+
+ it('should emit `timerange` event when a zooming in/out in a chart occcurs', () => {
+ const timeRange = {
+ start: '2020-01-01T00:00:00.000Z',
+ end: '2020-01-01T01:00:00.000Z',
+ };
+
+ jest.spyOn(wrapper.vm, '$emit');
+
+ findTimeChart().vm.$emit('datazoom', timeRange);
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('timerangezoom', timeRange);
+ });
+ });
+
+ it('includes a default group id', () => {
+ expect(wrapper.vm.groupId).toBe('dashboard-panel');
+ });
+
+ describe('Supports different panel types', () => {
+ const dataWithType = type => {
+ return {
+ ...graphData,
+ type,
+ };
+ };
+
+ 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);
+ });
+
+ it.each`
+ data | component
+ ${dataWithType(panelTypes.AREA_CHART)} | ${MonitorTimeSeriesChart}
+ ${dataWithType(panelTypes.LINE_CHART)} | ${MonitorTimeSeriesChart}
+ ${anomalyMockGraphData} | ${MonitorAnomalyChart}
+ ${dataWithType(panelTypes.COLUMN)} | ${MonitorColumnChart}
+ ${dataWithType(panelTypes.STACKED_COLUMN)} | ${MonitorStackedColumnChart}
+ ${singleStatMetricsResult} | ${MonitorSingleStatChart}
+ ${graphDataPrometheusQueryRangeMultiTrack} | ${MonitorHeatmapChart}
+ ${barMockData} | ${MonitorBarChart}
+ `('wrapps a $data.type component binding attributes', ({ data, component }) => {
+ const attrs = { attr1: 'attr1Value', attr2: 'attr2Value' };
+ createWrapper({ graphData: data }, { attrs });
+
+ expect(wrapper.find(component).exists()).toBe(true);
+ expect(wrapper.find(component).isVueInstance()).toBe(true);
+ expect(wrapper.find(component).attributes()).toMatchObject(attrs);
+ });
+ });
+ });
+
+ describe('Edit custom metric dropdown item', () => {
+ const findEditCustomMetricLink = () => wrapper.find({ ref: 'editMetricLink' });
+ const mockEditPath = '/root/kubernetes-gke-project/prometheus/metrics/23/edit';
+
+ beforeEach(() => {
+ createWrapper();
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('is not present if the panel is not a custom metric', () => {
+ expect(findEditCustomMetricLink().exists()).toBe(false);
+ });
+
+ it('is present when the panel contains an edit_path property', () => {
+ wrapper.setProps({
+ graphData: {
+ ...graphData,
+ metrics: [
+ {
+ ...graphData.metrics[0],
+ edit_path: mockEditPath,
+ },
+ ],
+ },
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(findEditCustomMetricLink().exists()).toBe(true);
+ expect(findEditCustomMetricLink().text()).toBe('Edit metric');
+ expect(findEditCustomMetricLink().attributes('href')).toBe(mockEditPath);
+ });
+ });
+
+ it('shows an "Edit metrics" link pointing to settingsPath for a panel with multiple metrics', () => {
+ wrapper.setProps({
+ graphData: {
+ ...graphData,
+ metrics: [
+ {
+ ...graphData.metrics[0],
+ edit_path: '/root/kubernetes-gke-project/prometheus/metrics/23/edit',
+ },
+ {
+ ...graphData.metrics[0],
+ edit_path: '/root/kubernetes-gke-project/prometheus/metrics/23/edit',
+ },
+ ],
+ },
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(findEditCustomMetricLink().text()).toBe('Edit metrics');
+ expect(findEditCustomMetricLink().attributes('href')).toBe(propsData.settingsPath);
+ });
+ });
+ });
+
+ describe('View Logs dropdown item', () => {
+ const findViewLogsLink = () => wrapper.find({ ref: 'viewLogsLink' });
+
+ beforeEach(() => {
+ createWrapper();
+ return wrapper.vm.$nextTick();
+ });
+
+ it('is not present by default', () =>
+ wrapper.vm.$nextTick(() => {
+ expect(findViewLogsLink().exists()).toBe(false);
+ }));
+
+ it('is not present if a time range is not set', () => {
+ state.logsPath = mockLogsPath;
+ state.timeRange = null;
+
+ return wrapper.vm.$nextTick(() => {
+ expect(findViewLogsLink().exists()).toBe(false);
+ });
+ });
+
+ it('is not present if the logs path is default', () => {
+ state.logsPath = invalidUrl;
+ state.timeRange = mockTimeRange;
+
+ return wrapper.vm.$nextTick(() => {
+ expect(findViewLogsLink().exists()).toBe(false);
+ });
+ });
+
+ it('is not present if the logs path is not set', () => {
+ state.logsPath = null;
+ state.timeRange = mockTimeRange;
+
+ return wrapper.vm.$nextTick(() => {
+ expect(findViewLogsLink().exists()).toBe(false);
+ });
+ });
+
+ it('is present when logs path and time a range is present', () => {
+ state.logsPath = mockLogsPath;
+ state.timeRange = mockTimeRange;
+
+ return wrapper.vm.$nextTick(() => {
+ expect(findViewLogsLink().attributes('href')).toMatch(mockLogsHref);
+ });
+ });
+
+ it('it is overriden when a datazoom event is received', () => {
+ state.logsPath = mockLogsPath;
+ state.timeRange = mockTimeRange;
+
+ const zoomedTimeRange = {
+ start: '2020-01-01T00:00:00.000Z',
+ end: '2020-01-01T01:00:00.000Z',
+ };
+
+ findTimeChart().vm.$emit('datazoom', zoomedTimeRange);
+
+ return wrapper.vm.$nextTick(() => {
+ const start = encodeURIComponent(zoomedTimeRange.start);
+ const end = encodeURIComponent(zoomedTimeRange.end);
+ expect(findViewLogsLink().attributes('href')).toMatch(
+ `${mockLogsPath}?start=${start}&end=${end}`,
+ );
+ });
+ });
+ });
+
+ describe('when cliboard data is available', () => {
+ const clipboardText = 'A value to copy.';
+
+ beforeEach(() => {
+ createWrapper({
+ clipboardText,
+ });
+ });
+
+ it('sets clipboard text on the dropdown', () => {
+ expect(findCopyLink().exists()).toBe(true);
+ expect(findCopyLink().element.dataset.clipboardText).toBe(clipboardText);
+ });
+
+ it('adds a copy button to the dropdown', () => {
+ expect(findCopyLink().text()).toContain('Copy link to chart');
+ });
+
+ it('opens a toast on click', () => {
+ findCopyLink().vm.$emit('click');
+
+ expect(wrapper.vm.$toast.show).toHaveBeenCalled();
+ });
+ });
+
+ describe('when cliboard data is not available', () => {
+ it('there is no "copy to clipboard" link for a null value', () => {
+ createWrapper({ clipboardText: null });
+ expect(findCopyLink().exists()).toBe(false);
+ });
+
+ it('there is no "copy to clipboard" link for an empty value', () => {
+ createWrapper({ clipboardText: '' });
+ expect(findCopyLink().exists()).toBe(false);
+ });
+ });
+
+ describe('when downloading metrics data as CSV', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(DashboardPanel, {
+ propsData: {
+ clipboardText: exampleText,
+ settingsPath: propsData.settingsPath,
+ graphData: {
+ y_label: 'metric',
+ ...graphData,
+ },
+ },
+ store,
+ });
+ return wrapper.vm.$nextTick();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('csvText', () => {
+ it('converts metrics data from json to csv', () => {
+ const header = `timestamp,${graphData.y_label}`;
+ const data = graphData.metrics[0].result[0].values;
+ const firstRow = `${data[0][0]},${data[0][1]}`;
+ const secondRow = `${data[1][0]},${data[1][1]}`;
+
+ expect(wrapper.vm.csvText).toMatch(`${header}\r\n${firstRow}\r\n${secondRow}\r\n`);
+ });
+ });
+
+ describe('downloadCsv', () => {
+ it('produces a link with a Blob', () => {
+ expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(expect.any(Blob));
+ expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ size: wrapper.vm.csvText.length,
+ type: 'text/plain',
+ }),
+ );
+ });
+ });
+ });
+
+ describe('when using dynamic modules', () => {
+ const { mockDeploymentData, mockProjectPath } = mockNamespacedData;
+
+ beforeEach(() => {
+ store = createEmbedGroupStore();
+ store.registerModule(mockNamespace, monitoringDashboard);
+ store.state.embedGroup.modules.push(mockNamespace);
+
+ wrapper = shallowMount(DashboardPanel, {
+ propsData: {
+ graphData,
+ settingsPath: propsData.settingsPath,
+ namespace: mockNamespace,
+ },
+ store,
+ mocks,
+ });
+ });
+
+ it('handles namespaced time range and logs path state', () => {
+ store.state[mockNamespace].timeRange = mockTimeRange;
+ store.state[mockNamespace].logsPath = mockLogsPath;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find({ ref: 'viewLogsLink' }).attributes().href).toBe(mockLogsHref);
+ });
+ });
+
+ it('handles namespaced deployment data state', () => {
+ store.state[mockNamespace].deploymentData = mockDeploymentData;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findTimeChart().props().deploymentData).toEqual(mockDeploymentData);
+ });
+ });
+
+ it('handles namespaced project path state', () => {
+ store.state[mockNamespace].projectPath = mockProjectPath;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findTimeChart().props().projectPath).toBe(mockProjectPath);
+ });
+ });
+
+ it('it renders a time series chart with no errors', () => {
+ expect(wrapper.find(MonitorTimeSeriesChart).isVueInstance()).toBe(true);
+ expect(wrapper.find(MonitorTimeSeriesChart).exists()).toBe(true);
+ });
+ });
+
+ describe('Expand to full screen', () => {
+ const findExpandBtn = () => wrapper.find({ ref: 'expandBtn' });
+
+ describe('when there is no @expand listener', () => {
+ it('does not show `View full screen` option', () => {
+ createWrapper();
+ expect(findExpandBtn().exists()).toBe(false);
+ });
+ });
+
+ describe('when there is an @expand listener', () => {
+ beforeEach(() => {
+ createWrapper({}, { listeners: { expand: () => {} } });
+ });
+
+ it('shows the `expand` option', () => {
+ expect(findExpandBtn().exists()).toBe(true);
+ });
+
+ it('emits the `expand` event', () => {
+ const preventDefault = jest.fn();
+ findExpandBtn().vm.$emit('click', { preventDefault });
+ expect(wrapper.emitted('expand')).toHaveLength(1);
+ expect(preventDefault).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('panel alerts', () => {
+ const setMetricsSavedToDb = val =>
+ monitoringDashboard.getters.metricsSavedToDb.mockReturnValue(val);
+ const findAlertsWidget = () => wrapper.find(AlertWidget);
+ const findMenuItemAlert = () =>
+ wrapper.findAll(GlDropdownItem).filter(i => i.text() === 'Alerts');
+
+ beforeEach(() => {
+ jest.spyOn(monitoringDashboard.getters, 'metricsSavedToDb').mockReturnValue([]);
+
+ store = new Vuex.Store({
+ modules: {
+ monitoringDashboard,
+ },
+ });
+
+ createWrapper();
+ });
+
+ describe.each`
+ desc | metricsSavedToDb | props | isShown
+ ${'with permission and no metrics in db'} | ${[]} | ${{}} | ${false}
+ ${'with permission and related metrics in db'} | ${[graphData.metrics[0].metricId]} | ${{}} | ${true}
+ ${'without permission and related metrics in db'} | ${[graphData.metrics[0].metricId]} | ${{ prometheusAlertsAvailable: false }} | ${false}
+ ${'with permission and unrelated metrics in db'} | ${['another_metric_id']} | ${{}} | ${false}
+ `('$desc', ({ metricsSavedToDb, isShown, props }) => {
+ const showsDesc = isShown ? 'shows' : 'does not show';
+
+ beforeEach(() => {
+ setMetricsSavedToDb(metricsSavedToDb);
+ createWrapper({
+ alertsEndpoint: '/endpoint',
+ prometheusAlertsAvailable: true,
+ ...props,
+ });
+ return wrapper.vm.$nextTick();
+ });
+
+ it(`${showsDesc} alert widget`, () => {
+ expect(findAlertsWidget().exists()).toBe(isShown);
+ });
+
+ it(`${showsDesc} alert configuration`, () => {
+ expect(findMenuItemAlert().exists()).toBe(isShown);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js
index 8b6ee9b3bf6..b2c9fe93cde 100644
--- a/spec/frontend/monitoring/components/dashboard_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_spec.js
@@ -1,6 +1,8 @@
import { shallowMount, mount } from '@vue/test-utils';
import Tracking from '~/tracking';
-import { GlModal, GlDropdownItem, GlDeprecatedButton } from '@gitlab/ui';
+import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys';
+import { GlModal, GlDropdownItem, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
+import { objectToQuery } from '~/lib/utils/url_utility';
import VueDraggable from 'vuedraggable';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
@@ -11,13 +13,23 @@ import Dashboard from '~/monitoring/components/dashboard.vue';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue';
import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue';
+import EmptyState from '~/monitoring/components/empty_state.vue';
import GroupEmptyState from '~/monitoring/components/group_empty_state.vue';
-import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
+import DashboardPanel from '~/monitoring/components/dashboard_panel.vue';
import { createStore } from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types';
-import { setupStoreWithDashboard, setMetricResult, setupStoreWithData } from '../store_utils';
+import {
+ setupAllDashboards,
+ setupStoreWithDashboard,
+ setMetricResult,
+ setupStoreWithData,
+ setupStoreWithVariable,
+} from '../store_utils';
import { environmentData, dashboardGitResponse, propsData } from '../mock_data';
import { metricsDashboardViewModel, metricsDashboardPanelCount } from '../fixture_data';
+import createFlash from '~/flash';
+
+jest.mock('~/flash');
describe('Dashboard', () => {
let store;
@@ -27,15 +39,12 @@ describe('Dashboard', () => {
const findEnvironmentsDropdown = () => wrapper.find({ ref: 'monitorEnvironmentsDropdown' });
const findAllEnvironmentsDropdownItems = () => findEnvironmentsDropdown().findAll(GlDropdownItem);
const setSearchTerm = searchTerm => {
- wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm);
+ store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm);
};
const createShallowWrapper = (props = {}, options = {}) => {
wrapper = shallowMount(Dashboard, {
propsData: { ...propsData, ...props },
- methods: {
- fetchData: jest.fn(),
- },
store,
...options,
});
@@ -44,10 +53,8 @@ describe('Dashboard', () => {
const createMountedWrapper = (props = {}, options = {}) => {
wrapper = mount(Dashboard, {
propsData: { ...propsData, ...props },
- methods: {
- fetchData: jest.fn(),
- },
store,
+ stubs: ['graph-group', 'dashboard-panel'],
...options,
});
};
@@ -55,19 +62,18 @@ describe('Dashboard', () => {
beforeEach(() => {
store = createStore();
mock = new MockAdapter(axios);
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
});
afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
mock.restore();
+ if (store.dispatch.mockReset) {
+ store.dispatch.mockReset();
+ }
});
describe('no metrics are available yet', () => {
beforeEach(() => {
- jest.spyOn(store, 'dispatch');
createShallowWrapper();
});
@@ -103,9 +109,7 @@ describe('Dashboard', () => {
describe('request information to the server', () => {
it('calls to set time range and fetch data', () => {
- jest.spyOn(store, 'dispatch');
-
- createShallowWrapper({ hasMetrics: true }, { methods: {} });
+ createShallowWrapper({ hasMetrics: true });
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith(
@@ -118,20 +122,20 @@ describe('Dashboard', () => {
});
it('shows up a loading state', () => {
- createShallowWrapper({ hasMetrics: true }, { methods: {} });
+ store.state.monitoringDashboard.emptyState = 'loading';
+
+ createShallowWrapper({ hasMetrics: true });
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.emptyState).toEqual('loading');
+ expect(wrapper.find(EmptyState).exists()).toBe(true);
+ expect(wrapper.find(EmptyState).props('selectedState')).toBe('loading');
});
});
it('hides the group panels when showPanels is false', () => {
- createMountedWrapper(
- { hasMetrics: true, showPanels: false },
- { stubs: ['graph-group', 'panel-type'] },
- );
+ createMountedWrapper({ hasMetrics: true, showPanels: false });
- setupStoreWithData(wrapper.vm.$store);
+ setupStoreWithData(store);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.showEmptyState).toEqual(false);
@@ -142,9 +146,9 @@ describe('Dashboard', () => {
it('fetches the metrics data with proper time window', () => {
jest.spyOn(store, 'dispatch');
- createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
+ createMountedWrapper({ hasMetrics: true });
- wrapper.vm.$store.commit(
+ store.commit(
`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`,
environmentData,
);
@@ -155,11 +159,176 @@ describe('Dashboard', () => {
});
});
+ describe('when the URL contains a reference to a panel', () => {
+ let location;
+
+ const setSearch = search => {
+ window.location = { ...location, search };
+ };
+
+ beforeEach(() => {
+ location = window.location;
+ delete window.location;
+ });
+
+ afterEach(() => {
+ window.location = location;
+ });
+
+ it('when the URL points to a panel it expands', () => {
+ const panelGroup = metricsDashboardViewModel.panelGroups[0];
+ const panel = panelGroup.panels[0];
+
+ setSearch(
+ objectToQuery({
+ group: panelGroup.group,
+ title: panel.title,
+ y_label: panel.y_label,
+ }),
+ );
+
+ createMountedWrapper({ hasMetrics: true });
+ setupStoreWithData(store);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setExpandedPanel', {
+ group: panelGroup.group,
+ panel: expect.objectContaining({
+ title: panel.title,
+ y_label: panel.y_label,
+ }),
+ });
+ });
+ });
+
+ it('when the URL does not link to any panel, no panel is expanded', () => {
+ setSearch('');
+
+ createMountedWrapper({ hasMetrics: true });
+ setupStoreWithData(store);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(store.dispatch).not.toHaveBeenCalledWith(
+ 'monitoringDashboard/setExpandedPanel',
+ expect.anything(),
+ );
+ });
+ });
+
+ it('when the URL points to an incorrect panel it shows an error', () => {
+ const panelGroup = metricsDashboardViewModel.panelGroups[0];
+ const panel = panelGroup.panels[0];
+
+ setSearch(
+ objectToQuery({
+ group: panelGroup.group,
+ title: 'incorrect',
+ y_label: panel.y_label,
+ }),
+ );
+
+ createMountedWrapper({ hasMetrics: true });
+ setupStoreWithData(store);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(createFlash).toHaveBeenCalled();
+ expect(store.dispatch).not.toHaveBeenCalledWith(
+ 'monitoringDashboard/setExpandedPanel',
+ expect.anything(),
+ );
+ });
+ });
+ });
+
+ describe('when the panel is expanded', () => {
+ let group;
+ let panel;
+
+ const expandPanel = (mockGroup, mockPanel) => {
+ store.commit(`monitoringDashboard/${types.SET_EXPANDED_PANEL}`, {
+ group: mockGroup,
+ panel: mockPanel,
+ });
+ };
+
+ beforeEach(() => {
+ setupStoreWithData(store);
+
+ const { panelGroups } = store.state.monitoringDashboard.dashboard;
+ group = panelGroups[0].group;
+ [panel] = panelGroups[0].panels;
+
+ jest.spyOn(window.history, 'pushState').mockImplementation();
+ });
+
+ afterEach(() => {
+ window.history.pushState.mockRestore();
+ });
+
+ it('URL is updated with panel parameters', () => {
+ createMountedWrapper({ hasMetrics: true });
+ expandPanel(group, panel);
+
+ const expectedSearch = objectToQuery({
+ group,
+ title: panel.title,
+ y_label: panel.y_label,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ expect.anything(), // state
+ expect.any(String), // document title
+ expect.stringContaining(`${expectedSearch}`),
+ );
+ });
+ });
+
+ it('URL is updated with panel parameters and custom dashboard', () => {
+ const dashboard = 'dashboard.yml';
+
+ createMountedWrapper({ hasMetrics: true, currentDashboard: dashboard });
+ expandPanel(group, panel);
+
+ const expectedSearch = objectToQuery({
+ dashboard,
+ group,
+ title: panel.title,
+ y_label: panel.y_label,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ expect.anything(), // state
+ expect.any(String), // document title
+ expect.stringContaining(`${expectedSearch}`),
+ );
+ });
+ });
+
+ it('URL is updated with no parameters', () => {
+ expandPanel(group, panel);
+ createMountedWrapper({ hasMetrics: true });
+ expandPanel(null, null);
+
+ return wrapper.vm.$nextTick(() => {
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ expect.anything(), // state
+ expect.any(String), // document title
+ expect.not.stringMatching(/group|title|y_label/), // no panel params
+ );
+ });
+ });
+ });
+
describe('when all requests have been commited by the store', () => {
beforeEach(() => {
- createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
+ createMountedWrapper({ hasMetrics: true });
- setupStoreWithData(wrapper.vm.$store);
+ setupStoreWithData(store);
return wrapper.vm.$nextTick();
});
@@ -185,10 +354,89 @@ describe('Dashboard', () => {
});
});
+ describe('star dashboards', () => {
+ const findToggleStar = () => wrapper.find({ ref: 'toggleStarBtn' });
+ const findToggleStarIcon = () => findToggleStar().find(GlIcon);
+
+ beforeEach(() => {
+ createShallowWrapper();
+ setupAllDashboards(store);
+ });
+
+ it('toggle star button is shown', () => {
+ expect(findToggleStar().exists()).toBe(true);
+ expect(findToggleStar().props('disabled')).toBe(false);
+ });
+
+ it('toggle star button is disabled when starring is taking place', () => {
+ store.commit(`monitoringDashboard/${types.REQUEST_DASHBOARD_STARRING}`);
+
+ return wrapper.vm.$nextTick(() => {
+ expect(findToggleStar().exists()).toBe(true);
+ expect(findToggleStar().props('disabled')).toBe(true);
+ });
+ });
+
+ describe('when the dashboard list is loaded', () => {
+ // Tooltip element should wrap directly
+ const getToggleTooltip = () => findToggleStar().element.parentElement.getAttribute('title');
+
+ beforeEach(() => {
+ setupAllDashboards(store);
+ jest.spyOn(store, 'dispatch');
+ });
+
+ it('dispatches a toggle star action', () => {
+ findToggleStar().vm.$emit('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'monitoringDashboard/toggleStarredValue',
+ undefined,
+ );
+ });
+ });
+
+ describe('when dashboard is not starred', () => {
+ beforeEach(() => {
+ store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, {
+ currentDashboard: dashboardGitResponse[0].path,
+ });
+ return wrapper.vm.$nextTick();
+ });
+
+ it('toggle star button shows "Star dashboard"', () => {
+ expect(getToggleTooltip()).toBe('Star dashboard');
+ });
+
+ it('toggle star button shows an unstarred state', () => {
+ expect(findToggleStarIcon().attributes('name')).toBe('star-o');
+ });
+ });
+
+ describe('when dashboard is starred', () => {
+ beforeEach(() => {
+ store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, {
+ currentDashboard: dashboardGitResponse[1].path,
+ });
+ return wrapper.vm.$nextTick();
+ });
+
+ it('toggle star button shows "Star dashboard"', () => {
+ expect(getToggleTooltip()).toBe('Unstar dashboard');
+ });
+
+ it('toggle star button shows a starred state', () => {
+ expect(findToggleStarIcon().attributes('name')).toBe('star');
+ });
+ });
+ });
+ });
+
it('hides the environments dropdown list when there is no environments', () => {
- createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
+ createMountedWrapper({ hasMetrics: true });
- setupStoreWithDashboard(wrapper.vm.$store);
+ setupStoreWithDashboard(store);
return wrapper.vm.$nextTick().then(() => {
expect(findAllEnvironmentsDropdownItems()).toHaveLength(0);
@@ -196,9 +444,9 @@ describe('Dashboard', () => {
});
it('renders the datetimepicker dropdown', () => {
- createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
+ createMountedWrapper({ hasMetrics: true });
- setupStoreWithData(wrapper.vm.$store);
+ setupStoreWithData(store);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(DateTimePicker).exists()).toBe(true);
@@ -206,9 +454,9 @@ describe('Dashboard', () => {
});
it('renders the refresh dashboard button', () => {
- createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
+ createMountedWrapper({ hasMetrics: true });
- setupStoreWithData(wrapper.vm.$store);
+ setupStoreWithData(store);
return wrapper.vm.$nextTick().then(() => {
const refreshBtn = wrapper.findAll({ ref: 'refreshDashboardBtn' });
@@ -218,14 +466,135 @@ describe('Dashboard', () => {
});
});
- describe('when one of the metrics is missing', () => {
+ describe('variables section', () => {
beforeEach(() => {
createShallowWrapper({ hasMetrics: true });
+ setupStoreWithData(store);
+ setupStoreWithVariable(store);
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('shows the variables section', () => {
+ expect(wrapper.vm.shouldShowVariablesSection).toBe(true);
+ });
+ });
+
+ describe('single panel expands to "full screen" mode', () => {
+ const findExpandedPanel = () => wrapper.find({ ref: 'expandedPanel' });
- const { $store } = wrapper.vm;
+ describe('when the panel is not expanded', () => {
+ beforeEach(() => {
+ createShallowWrapper({ hasMetrics: true });
+ setupStoreWithData(store);
+ return wrapper.vm.$nextTick();
+ });
+
+ it('expanded panel is not visible', () => {
+ expect(findExpandedPanel().isVisible()).toBe(false);
+ });
+
+ it('can set a panel as expanded', () => {
+ const panel = wrapper.findAll(DashboardPanel).at(1);
+
+ jest.spyOn(store, 'dispatch');
+
+ panel.vm.$emit('expand');
+
+ const groupData = metricsDashboardViewModel.panelGroups[0];
+
+ expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setExpandedPanel', {
+ group: groupData.group,
+ panel: expect.objectContaining({
+ id: groupData.panels[0].id,
+ }),
+ });
+ });
+ });
+
+ describe('when the panel is expanded', () => {
+ let group;
+ let panel;
+
+ const mockKeyup = key => window.dispatchEvent(new KeyboardEvent('keyup', { key }));
+
+ const MockPanel = {
+ template: `<div><slot name="topLeft"/></div>`,
+ };
+
+ beforeEach(() => {
+ createShallowWrapper({ hasMetrics: true }, { stubs: { DashboardPanel: MockPanel } });
+ setupStoreWithData(store);
+
+ const { panelGroups } = store.state.monitoringDashboard.dashboard;
+
+ group = panelGroups[0].group;
+ [panel] = panelGroups[0].panels;
+
+ store.commit(`monitoringDashboard/${types.SET_EXPANDED_PANEL}`, {
+ group,
+ panel,
+ });
+
+ jest.spyOn(store, 'dispatch');
+
+ return wrapper.vm.$nextTick();
+ });
- setupStoreWithDashboard($store);
- setMetricResult({ $store, result: [], panel: 2 });
+ it('displays a single panel and others are hidden', () => {
+ const panels = wrapper.findAll(MockPanel);
+ const visiblePanels = panels.filter(w => w.isVisible());
+
+ expect(findExpandedPanel().isVisible()).toBe(true);
+ // v-show for hiding panels is more performant than v-if
+ // check for panels to be hidden.
+ expect(panels.length).toBe(metricsDashboardPanelCount + 1);
+ expect(visiblePanels.length).toBe(1);
+ });
+
+ it('sets a link to the expanded panel', () => {
+ const searchQuery =
+ '?dashboard=config%2Fprometheus%2Fcommon_metrics.yml&group=System%20metrics%20(Kubernetes)&title=Memory%20Usage%20(Total)&y_label=Total%20Memory%20Used%20(GB)';
+
+ expect(findExpandedPanel().attributes('clipboard-text')).toEqual(
+ expect.stringContaining(searchQuery),
+ );
+ });
+
+ it('restores full dashboard by clicking `back`', () => {
+ wrapper.find({ ref: 'goBackBtn' }).vm.$emit('click');
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'monitoringDashboard/clearExpandedPanel',
+ undefined,
+ );
+ });
+
+ it('restores dashboard from full screen by typing the Escape key', () => {
+ mockKeyup(ESC_KEY);
+ expect(store.dispatch).toHaveBeenCalledWith(
+ `monitoringDashboard/clearExpandedPanel`,
+ undefined,
+ );
+ });
+
+ it('restores dashboard from full screen by typing the Escape key on IE11', () => {
+ mockKeyup(ESC_KEY_IE11);
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ `monitoringDashboard/clearExpandedPanel`,
+ undefined,
+ );
+ });
+ });
+ });
+
+ describe('when one of the metrics is missing', () => {
+ beforeEach(() => {
+ createShallowWrapper({ hasMetrics: true });
+
+ setupStoreWithDashboard(store);
+ setMetricResult({ store, result: [], panel: 2 });
return wrapper.vm.$nextTick();
});
@@ -249,19 +618,17 @@ describe('Dashboard', () => {
describe('searchable environments dropdown', () => {
beforeEach(() => {
- createMountedWrapper(
- { hasMetrics: true },
- {
- attachToDocument: true,
- stubs: ['graph-group', 'panel-type'],
- },
- );
+ createMountedWrapper({ hasMetrics: true }, { attachToDocument: true });
- setupStoreWithData(wrapper.vm.$store);
+ setupStoreWithData(store);
return wrapper.vm.$nextTick();
});
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
it('renders a search input', () => {
expect(wrapper.find({ ref: 'monitorEnvironmentsDropdownSearch' }).exists()).toBe(true);
});
@@ -304,7 +671,7 @@ describe('Dashboard', () => {
});
it('shows loading element when environments fetch is still loading', () => {
- wrapper.vm.$store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`);
+ store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`);
return wrapper.vm
.$nextTick()
@@ -312,7 +679,7 @@ describe('Dashboard', () => {
expect(wrapper.find({ ref: 'monitorEnvironmentsDropdownLoading' }).exists()).toBe(true);
})
.then(() => {
- wrapper.vm.$store.commit(
+ store.commit(
`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`,
environmentData,
);
@@ -330,9 +697,11 @@ describe('Dashboard', () => {
const findRearrangeButton = () => wrapper.find('.js-rearrange-button');
beforeEach(() => {
- createShallowWrapper({ hasMetrics: true });
+ // call original dispatch
+ store.dispatch.mockRestore();
- setupStoreWithData(wrapper.vm.$store);
+ createShallowWrapper({ hasMetrics: true });
+ setupStoreWithData(store);
return wrapper.vm.$nextTick();
});
@@ -420,7 +789,7 @@ describe('Dashboard', () => {
createShallowWrapper({ hasMetrics: true, showHeader: false });
// all_dashboards is not defined in health dashboards
- wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, undefined);
+ store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, undefined);
return wrapper.vm.$nextTick();
});
@@ -440,10 +809,7 @@ describe('Dashboard', () => {
beforeEach(() => {
createShallowWrapper({ hasMetrics: true });
- wrapper.vm.$store.commit(
- `monitoringDashboard/${types.SET_ALL_DASHBOARDS}`,
- dashboardGitResponse,
- );
+ setupAllDashboards(store);
return wrapper.vm.$nextTick();
});
@@ -452,10 +818,11 @@ describe('Dashboard', () => {
});
it('is present for a custom dashboard, and links to its edit_path', () => {
- const dashboard = dashboardGitResponse[1]; // non-default dashboard
- const currentDashboard = dashboard.path;
+ const dashboard = dashboardGitResponse[1];
+ store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, {
+ currentDashboard: dashboard.path,
+ });
- wrapper.setProps({ currentDashboard });
return wrapper.vm.$nextTick().then(() => {
expect(findEditLink().exists()).toBe(true);
expect(findEditLink().attributes('href')).toBe(dashboard.project_blob_path);
@@ -465,13 +832,8 @@ describe('Dashboard', () => {
describe('Dashboard dropdown', () => {
beforeEach(() => {
- createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
-
- wrapper.vm.$store.commit(
- `monitoringDashboard/${types.SET_ALL_DASHBOARDS}`,
- dashboardGitResponse,
- );
-
+ createMountedWrapper({ hasMetrics: true });
+ setupAllDashboards(store);
return wrapper.vm.$nextTick();
});
@@ -484,15 +846,12 @@ describe('Dashboard', () => {
describe('external dashboard link', () => {
beforeEach(() => {
- createMountedWrapper(
- {
- hasMetrics: true,
- showPanels: false,
- showTimeWindowDropdown: false,
- externalDashboardUrl: '/mockUrl',
- },
- { stubs: ['graph-group', 'panel-type'] },
- );
+ createMountedWrapper({
+ hasMetrics: true,
+ showPanels: false,
+ showTimeWindowDropdown: false,
+ externalDashboardUrl: '/mockUrl',
+ });
return wrapper.vm.$nextTick();
});
@@ -507,45 +866,29 @@ describe('Dashboard', () => {
});
describe('Clipboard text in panels', () => {
- const currentDashboard = 'TEST_DASHBOARD';
+ const currentDashboard = dashboardGitResponse[1].path;
+ const panelIndex = 1; // skip expanded panel
- const getClipboardTextAt = i =>
+ const getClipboardTextFirstPanel = () =>
wrapper
- .findAll(PanelType)
- .at(i)
+ .findAll(DashboardPanel)
+ .at(panelIndex)
.props('clipboardText');
beforeEach(() => {
+ setupStoreWithData(store);
createShallowWrapper({ hasMetrics: true, currentDashboard });
- setupStoreWithData(wrapper.vm.$store);
-
return wrapper.vm.$nextTick();
});
it('contains a link to the dashboard', () => {
- expect(getClipboardTextAt(0)).toContain(`dashboard=${currentDashboard}`);
- expect(getClipboardTextAt(0)).toContain(`group=`);
- expect(getClipboardTextAt(0)).toContain(`title=`);
- expect(getClipboardTextAt(0)).toContain(`y_label=`);
- });
-
- it('strips the undefined parameter', () => {
- wrapper.setProps({ currentDashboard: undefined });
-
- return wrapper.vm.$nextTick(() => {
- expect(getClipboardTextAt(0)).not.toContain(`dashboard=`);
- expect(getClipboardTextAt(0)).toContain(`y_label=`);
- });
- });
+ const dashboardParam = `dashboard=${encodeURIComponent(currentDashboard)}`;
- it('null parameter is stripped', () => {
- wrapper.setProps({ currentDashboard: null });
-
- return wrapper.vm.$nextTick(() => {
- expect(getClipboardTextAt(0)).not.toContain(`dashboard=`);
- expect(getClipboardTextAt(0)).toContain(`y_label=`);
- });
+ expect(getClipboardTextFirstPanel()).toContain(dashboardParam);
+ expect(getClipboardTextFirstPanel()).toContain(`group=`);
+ expect(getClipboardTextFirstPanel()).toContain(`title=`);
+ expect(getClipboardTextFirstPanel()).toContain(`y_label=`);
});
});
@@ -572,7 +915,7 @@ describe('Dashboard', () => {
customMetricsPath: '/endpoint',
customMetricsAvailable: true,
});
- setupStoreWithData(wrapper.vm.$store);
+ setupStoreWithData(store);
origPage = document.body.dataset.page;
document.body.dataset.page = 'projects:environments:metrics';
diff --git a/spec/frontend/monitoring/components/dashboard_template_spec.js b/spec/frontend/monitoring/components/dashboard_template_spec.js
index d1790df4189..cc0ac348b11 100644
--- a/spec/frontend/monitoring/components/dashboard_template_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_template_spec.js
@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores';
+import { setupAllDashboards } from '../store_utils';
import { propsData } from '../mock_data';
jest.mock('~/lib/utils/url_utility');
@@ -15,24 +16,16 @@ describe('Dashboard template', () => {
beforeEach(() => {
store = createStore();
mock = new MockAdapter(axios);
+
+ setupAllDashboards(store);
});
afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
mock.restore();
});
it('matches the default snapshot', () => {
- wrapper = shallowMount(Dashboard, {
- propsData: { ...propsData },
- methods: {
- fetchData: jest.fn(),
- },
- store,
- });
+ wrapper = shallowMount(Dashboard, { propsData: { ...propsData }, store });
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js
index 65e9d036d1a..9bba5280007 100644
--- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js
@@ -27,7 +27,7 @@ describe('dashboard invalid url parameters', () => {
wrapper = mount(Dashboard, {
propsData: { ...propsData, ...props },
store,
- stubs: ['graph-group', 'panel-type'],
+ stubs: ['graph-group', 'dashboard-panel'],
...options,
});
};
diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js
index 0bcfabe6415..b29d86cbc5b 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 { GlDropdownItem, GlModal, GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import { GlDropdownItem, GlModal, GlLoadingIcon, GlAlert, GlIcon } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue';
@@ -9,36 +9,48 @@ import { dashboardGitResponse } from '../mock_data';
const defaultBranch = 'master';
-function createComponent(props, opts = {}) {
- const storeOpts = {
- methods: {
- duplicateSystemDashboard: jest.fn(),
- },
- computed: {
- allDashboards: () => dashboardGitResponse,
- },
- };
-
- return shallowMount(DashboardsDropdown, {
- propsData: {
- ...props,
- defaultBranch,
- },
- sync: false,
- ...storeOpts,
- ...opts,
- });
-}
+const starredDashboards = dashboardGitResponse.filter(({ starred }) => starred);
+const notStarredDashboards = dashboardGitResponse.filter(({ starred }) => !starred);
describe('DashboardsDropdown', () => {
let wrapper;
+ let mockDashboards;
+ let mockSelectedDashboard;
+
+ function createComponent(props, opts = {}) {
+ const storeOpts = {
+ methods: {
+ duplicateSystemDashboard: jest.fn(),
+ },
+ computed: {
+ allDashboards: () => mockDashboards,
+ selectedDashboard: () => mockSelectedDashboard,
+ },
+ };
+
+ return shallowMount(DashboardsDropdown, {
+ propsData: {
+ ...props,
+ defaultBranch,
+ },
+ sync: false,
+ ...storeOpts,
+ ...opts,
+ });
+ }
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' });
const setSearchTerm = searchTerm => wrapper.setData({ searchTerm });
+ beforeEach(() => {
+ mockDashboards = dashboardGitResponse;
+ mockSelectedDashboard = null;
+ });
+
describe('when it receives dashboards data', () => {
beforeEach(() => {
wrapper = createComponent();
@@ -48,10 +60,14 @@ describe('DashboardsDropdown', () => {
expect(findItems().length).toEqual(dashboardGitResponse.length);
});
- it('displays items with the dashboard display name', () => {
- expect(findItemAt(0).text()).toBe(dashboardGitResponse[0].display_name);
- expect(findItemAt(1).text()).toBe(dashboardGitResponse[1].display_name);
- expect(findItemAt(2).text()).toBe(dashboardGitResponse[2].display_name);
+ it('displays items with the dashboard display name, with starred dashboards first', () => {
+ expect(findItemAt(0).text()).toBe(starredDashboards[0].display_name);
+ expect(findItemAt(1).text()).toBe(notStarredDashboards[0].display_name);
+ expect(findItemAt(2).text()).toBe(notStarredDashboards[1].display_name);
+ });
+
+ it('displays separator between starred and not starred dashboards', () => {
+ expect(findStarredListDivider().exists()).toBe(true);
});
it('displays a search input', () => {
@@ -81,18 +97,71 @@ describe('DashboardsDropdown', () => {
});
});
+ describe('when the dashboard is missing a display name', () => {
+ beforeEach(() => {
+ mockDashboards = dashboardGitResponse.map(d => ({ ...d, display_name: undefined }));
+ wrapper = createComponent();
+ });
+
+ it('displays items with the dashboard path, with starred dashboards first', () => {
+ expect(findItemAt(0).text()).toBe(starredDashboards[0].path);
+ expect(findItemAt(1).text()).toBe(notStarredDashboards[0].path);
+ expect(findItemAt(2).text()).toBe(notStarredDashboards[1].path);
+ });
+ });
+
+ describe('when it receives starred dashboards', () => {
+ beforeEach(() => {
+ mockDashboards = starredDashboards;
+ wrapper = createComponent();
+ });
+
+ it('displays an item for each dashboard', () => {
+ expect(findItems().length).toEqual(starredDashboards.length);
+ });
+
+ it('displays a star icon', () => {
+ const star = findItemAt(0).find(GlIcon);
+ expect(star.exists()).toBe(true);
+ expect(star.attributes('name')).toBe('star');
+ });
+
+ it('displays no separator between starred and not starred dashboards', () => {
+ expect(findStarredListDivider().exists()).toBe(false);
+ });
+ });
+
+ describe('when it receives only not-starred dashboards', () => {
+ beforeEach(() => {
+ mockDashboards = notStarredDashboards;
+ wrapper = createComponent();
+ });
+
+ it('displays an item for each dashboard', () => {
+ expect(findItems().length).toEqual(notStarredDashboards.length);
+ });
+
+ it('displays no star icon', () => {
+ const star = findItemAt(0).find(GlIcon);
+ expect(star.exists()).toBe(false);
+ });
+
+ it('displays no separator between starred and not starred dashboards', () => {
+ expect(findStarredListDivider().exists()).toBe(false);
+ });
+ });
+
describe('when a system dashboard is selected', () => {
let duplicateDashboardAction;
let modalDirective;
beforeEach(() => {
+ [mockSelectedDashboard] = dashboardGitResponse;
modalDirective = jest.fn();
duplicateDashboardAction = jest.fn().mockResolvedValue();
wrapper = createComponent(
- {
- selectedDashboard: dashboardGitResponse[0],
- },
+ {},
{
directives: {
GlModal: modalDirective,
@@ -260,7 +329,7 @@ describe('DashboardsDropdown', () => {
expect(wrapper.emitted().selectDashboard).toBeTruthy();
});
it('emits a "selectDashboard" event with dashboard information', () => {
- expect(wrapper.emitted().selectDashboard[0]).toEqual([dashboardGitResponse[1]]);
+ expect(wrapper.emitted().selectDashboard[0]).toEqual([dashboardGitResponse[0]]);
});
});
});
diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js
index 10fd58f749d..216ec345552 100644
--- a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js
+++ b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js
@@ -81,7 +81,8 @@ describe('DuplicateDashboardForm', () => {
it('with the inital form values', () => {
expect(wrapper.emitted().change).toHaveLength(1);
- expect(lastChange()).resolves.toEqual({
+
+ return expect(lastChange()).resolves.toEqual({
branch: '',
commitMessage: expect.any(String),
dashboard: dashboardGitResponse[0].path,
@@ -92,7 +93,7 @@ describe('DuplicateDashboardForm', () => {
it('containing an inputted file name', () => {
setValue('fileName', 'my_dashboard.yml');
- expect(lastChange()).resolves.toMatchObject({
+ return expect(lastChange()).resolves.toMatchObject({
fileName: 'my_dashboard.yml',
});
});
@@ -100,7 +101,7 @@ describe('DuplicateDashboardForm', () => {
it('containing a default commit message when no message is set', () => {
setValue('commitMessage', '');
- expect(lastChange()).resolves.toMatchObject({
+ return expect(lastChange()).resolves.toMatchObject({
commitMessage: expect.stringContaining('Create custom dashboard'),
});
});
@@ -108,7 +109,7 @@ describe('DuplicateDashboardForm', () => {
it('containing an inputted commit message', () => {
setValue('commitMessage', 'My commit message');
- expect(lastChange()).resolves.toMatchObject({
+ return expect(lastChange()).resolves.toMatchObject({
commitMessage: expect.stringContaining('My commit message'),
});
});
@@ -116,7 +117,7 @@ describe('DuplicateDashboardForm', () => {
it('containing an inputted branch name', () => {
setValue('branchName', 'a-new-branch');
- expect(lastChange()).resolves.toMatchObject({
+ return expect(lastChange()).resolves.toMatchObject({
branch: 'a-new-branch',
});
});
@@ -125,13 +126,14 @@ describe('DuplicateDashboardForm', () => {
setChecked(wrapper.vm.$options.radioVals.DEFAULT);
setValue('branchName', 'a-new-branch');
- expect(lastChange()).resolves.toMatchObject({
- branch: defaultBranch,
- });
-
- return wrapper.vm.$nextTick(() => {
- expect(findByRef('branchName').isVisible()).toBe(false);
- });
+ return Promise.all([
+ expect(lastChange()).resolves.toMatchObject({
+ branch: defaultBranch,
+ }),
+ wrapper.vm.$nextTick(() => {
+ expect(findByRef('branchName').isVisible()).toBe(false);
+ }),
+ ]);
});
it('when `new` branch option is chosen, focuses on the branch name input', () => {
diff --git a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js
index b829cd53479..f23823ccad6 100644
--- a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js
+++ b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js
@@ -1,6 +1,6 @@
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
-import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
+import DashboardPanel from '~/monitoring/components/dashboard_panel.vue';
import { TEST_HOST } from 'helpers/test_constants';
import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue';
import { groups, initialState, metricsData, metricsWithData } from './mock_data';
@@ -62,7 +62,7 @@ describe('MetricEmbed', () => {
it('shows an empty state when no metrics are present', () => {
expect(wrapper.find('.metrics-embed').exists()).toBe(true);
- expect(wrapper.find(PanelType).exists()).toBe(false);
+ expect(wrapper.find(DashboardPanel).exists()).toBe(false);
});
});
@@ -90,12 +90,12 @@ describe('MetricEmbed', () => {
it('shows a chart when metrics are present', () => {
expect(wrapper.find('.metrics-embed').exists()).toBe(true);
- expect(wrapper.find(PanelType).exists()).toBe(true);
- expect(wrapper.findAll(PanelType).length).toBe(2);
+ expect(wrapper.find(DashboardPanel).exists()).toBe(true);
+ expect(wrapper.findAll(DashboardPanel).length).toBe(2);
});
it('includes groupId with dashboardUrl', () => {
- expect(wrapper.find(PanelType).props('groupId')).toBe(TEST_HOST);
+ expect(wrapper.find(DashboardPanel).props('groupId')).toBe(TEST_HOST);
});
});
});
diff --git a/spec/frontend/monitoring/components/panel_type_spec.js b/spec/frontend/monitoring/components/panel_type_spec.js
deleted file mode 100644
index 819b5235284..00000000000
--- a/spec/frontend/monitoring/components/panel_type_spec.js
+++ /dev/null
@@ -1,408 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import AxiosMockAdapter from 'axios-mock-adapter';
-import { setTestTimeout } from 'helpers/timeout';
-import invalidUrl from '~/lib/utils/invalid_url';
-import axios from '~/lib/utils/axios_utils';
-
-import PanelType from '~/monitoring/components/panel_type.vue';
-import EmptyChart from '~/monitoring/components/charts/empty_chart.vue';
-import TimeSeriesChart from '~/monitoring/components/charts/time_series.vue';
-import AnomalyChart from '~/monitoring/components/charts/anomaly.vue';
-import {
- anomalyMockGraphData,
- mockLogsHref,
- mockLogsPath,
- mockNamespace,
- mockNamespacedData,
- mockTimeRange,
-} from '../mock_data';
-
-import { graphData, graphDataEmpty } from '../fixture_data';
-import { createStore, monitoringDashboard } from '~/monitoring/stores';
-import { createStore as createEmbedGroupStore } from '~/monitoring/stores/embed_group';
-
-global.URL.createObjectURL = jest.fn();
-
-const mocks = {
- $toast: {
- show: jest.fn(),
- },
-};
-
-describe('Panel Type component', () => {
- let axiosMock;
- let store;
- let state;
- let wrapper;
-
- const exampleText = 'example_text';
-
- const findCopyLink = () => wrapper.find({ ref: 'copyChartLink' });
- const findTimeChart = () => wrapper.find({ ref: 'timeChart' });
- const findTitle = () => wrapper.find({ ref: 'graphTitle' });
- const findContextualMenu = () => wrapper.find({ ref: 'contextualMenu' });
-
- const createWrapper = props => {
- wrapper = shallowMount(PanelType, {
- propsData: {
- graphData,
- ...props,
- },
- store,
- mocks,
- });
- };
-
- beforeEach(() => {
- setTestTimeout(1000);
-
- store = createStore();
- state = store.state.monitoringDashboard;
-
- axiosMock = new AxiosMockAdapter(axios);
- });
-
- afterEach(() => {
- axiosMock.reset();
- });
-
- describe('When no graphData is available', () => {
- beforeEach(() => {
- createWrapper({
- graphData: graphDataEmpty,
- });
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('Empty Chart component', () => {
- it('renders the chart title', () => {
- expect(findTitle().text()).toBe(graphDataEmpty.title);
- });
-
- it('renders the no download csv link', () => {
- expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(false);
- });
-
- it('does not contain graph widgets', () => {
- expect(findContextualMenu().exists()).toBe(false);
- });
-
- it('is a Vue instance', () => {
- expect(wrapper.find(EmptyChart).exists()).toBe(true);
- expect(wrapper.find(EmptyChart).isVueInstance()).toBe(true);
- });
- });
- });
-
- describe('when graph data is available', () => {
- beforeEach(() => {
- createWrapper();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders the chart title', () => {
- expect(findTitle().text()).toBe(graphData.title);
- });
-
- it('contains graph widgets', () => {
- expect(findContextualMenu().exists()).toBe(true);
- expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(true);
- });
-
- it('sets no clipboard copy link on dropdown by default', () => {
- expect(findCopyLink().exists()).toBe(false);
- });
-
- it('should emit `timerange` event when a zooming in/out in a chart occcurs', () => {
- const timeRange = {
- start: '2020-01-01T00:00:00.000Z',
- end: '2020-01-01T01:00:00.000Z',
- };
-
- jest.spyOn(wrapper.vm, '$emit');
-
- findTimeChart().vm.$emit('datazoom', timeRange);
-
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('timerangezoom', timeRange);
- });
- });
-
- describe('Time Series Chart panel type', () => {
- it('is rendered', () => {
- expect(wrapper.find(TimeSeriesChart).isVueInstance()).toBe(true);
- expect(wrapper.find(TimeSeriesChart).exists()).toBe(true);
- });
-
- it('includes a default group id', () => {
- expect(wrapper.vm.groupId).toBe('panel-type-chart');
- });
- });
-
- describe('Anomaly Chart panel type', () => {
- beforeEach(() => {
- wrapper.setProps({
- graphData: anomalyMockGraphData,
- });
- return wrapper.vm.$nextTick();
- });
-
- it('is rendered with an anomaly chart', () => {
- expect(wrapper.find(AnomalyChart).isVueInstance()).toBe(true);
- expect(wrapper.find(AnomalyChart).exists()).toBe(true);
- });
- });
- });
-
- describe('Edit custom metric dropdown item', () => {
- const findEditCustomMetricLink = () => wrapper.find({ ref: 'editMetricLink' });
-
- beforeEach(() => {
- createWrapper();
-
- return wrapper.vm.$nextTick();
- });
-
- it('is not present if the panel is not a custom metric', () => {
- expect(findEditCustomMetricLink().exists()).toBe(false);
- });
-
- it('is present when the panel contains an edit_path property', () => {
- wrapper.setProps({
- graphData: {
- ...graphData,
- metrics: [
- {
- ...graphData.metrics[0],
- edit_path: '/root/kubernetes-gke-project/prometheus/metrics/23/edit',
- },
- ],
- },
- });
-
- return wrapper.vm.$nextTick(() => {
- expect(findEditCustomMetricLink().exists()).toBe(true);
- expect(findEditCustomMetricLink().text()).toBe('Edit metric');
- });
- });
-
- it('shows an "Edit metrics" link for a panel with multiple metrics', () => {
- wrapper.setProps({
- graphData: {
- ...graphData,
- metrics: [
- {
- ...graphData.metrics[0],
- edit_path: '/root/kubernetes-gke-project/prometheus/metrics/23/edit',
- },
- {
- ...graphData.metrics[0],
- edit_path: '/root/kubernetes-gke-project/prometheus/metrics/23/edit',
- },
- ],
- },
- });
-
- return wrapper.vm.$nextTick(() => {
- expect(findEditCustomMetricLink().text()).toBe('Edit metrics');
- });
- });
- });
-
- describe('View Logs dropdown item', () => {
- const findViewLogsLink = () => wrapper.find({ ref: 'viewLogsLink' });
-
- beforeEach(() => {
- createWrapper();
- return wrapper.vm.$nextTick();
- });
-
- it('is not present by default', () =>
- wrapper.vm.$nextTick(() => {
- expect(findViewLogsLink().exists()).toBe(false);
- }));
-
- it('is not present if a time range is not set', () => {
- state.logsPath = mockLogsPath;
- state.timeRange = null;
-
- return wrapper.vm.$nextTick(() => {
- expect(findViewLogsLink().exists()).toBe(false);
- });
- });
-
- it('is not present if the logs path is default', () => {
- state.logsPath = invalidUrl;
- state.timeRange = mockTimeRange;
-
- return wrapper.vm.$nextTick(() => {
- expect(findViewLogsLink().exists()).toBe(false);
- });
- });
-
- it('is not present if the logs path is not set', () => {
- state.logsPath = null;
- state.timeRange = mockTimeRange;
-
- return wrapper.vm.$nextTick(() => {
- expect(findViewLogsLink().exists()).toBe(false);
- });
- });
-
- it('is present when logs path and time a range is present', () => {
- state.logsPath = mockLogsPath;
- state.timeRange = mockTimeRange;
-
- return wrapper.vm.$nextTick(() => {
- expect(findViewLogsLink().attributes('href')).toMatch(mockLogsHref);
- });
- });
-
- it('it is overriden when a datazoom event is received', () => {
- state.logsPath = mockLogsPath;
- state.timeRange = mockTimeRange;
-
- const zoomedTimeRange = {
- start: '2020-01-01T00:00:00.000Z',
- end: '2020-01-01T01:00:00.000Z',
- };
-
- findTimeChart().vm.$emit('datazoom', zoomedTimeRange);
-
- return wrapper.vm.$nextTick(() => {
- const start = encodeURIComponent(zoomedTimeRange.start);
- const end = encodeURIComponent(zoomedTimeRange.end);
- expect(findViewLogsLink().attributes('href')).toMatch(
- `${mockLogsPath}?start=${start}&end=${end}`,
- );
- });
- });
- });
-
- describe('when cliboard data is available', () => {
- const clipboardText = 'A value to copy.';
-
- beforeEach(() => {
- createWrapper({
- clipboardText,
- });
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('sets clipboard text on the dropdown', () => {
- expect(findCopyLink().exists()).toBe(true);
- expect(findCopyLink().element.dataset.clipboardText).toBe(clipboardText);
- });
-
- it('adds a copy button to the dropdown', () => {
- expect(findCopyLink().text()).toContain('Copy link to chart');
- });
-
- it('opens a toast on click', () => {
- findCopyLink().vm.$emit('click');
-
- expect(wrapper.vm.$toast.show).toHaveBeenCalled();
- });
- });
-
- describe('when downloading metrics data as CSV', () => {
- beforeEach(() => {
- wrapper = shallowMount(PanelType, {
- propsData: {
- clipboardText: exampleText,
- graphData: {
- y_label: 'metric',
- ...graphData,
- },
- },
- store,
- });
- return wrapper.vm.$nextTick();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('csvText', () => {
- it('converts metrics data from json to csv', () => {
- const header = `timestamp,${graphData.y_label}`;
- const data = graphData.metrics[0].result[0].values;
- const firstRow = `${data[0][0]},${data[0][1]}`;
- const secondRow = `${data[1][0]},${data[1][1]}`;
-
- expect(wrapper.vm.csvText).toMatch(`${header}\r\n${firstRow}\r\n${secondRow}\r\n`);
- });
- });
-
- describe('downloadCsv', () => {
- it('produces a link with a Blob', () => {
- expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(expect.any(Blob));
- expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(
- expect.objectContaining({
- size: wrapper.vm.csvText.length,
- type: 'text/plain',
- }),
- );
- });
- });
- });
-
- describe('when using dynamic modules', () => {
- const { mockDeploymentData, mockProjectPath } = mockNamespacedData;
-
- beforeEach(() => {
- store = createEmbedGroupStore();
- store.registerModule(mockNamespace, monitoringDashboard);
- store.state.embedGroup.modules.push(mockNamespace);
-
- wrapper = shallowMount(PanelType, {
- propsData: {
- graphData,
- namespace: mockNamespace,
- },
- store,
- mocks,
- });
- });
-
- it('handles namespaced time range and logs path state', () => {
- store.state[mockNamespace].timeRange = mockTimeRange;
- store.state[mockNamespace].logsPath = mockLogsPath;
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find({ ref: 'viewLogsLink' }).attributes().href).toBe(mockLogsHref);
- });
- });
-
- it('handles namespaced deployment data state', () => {
- store.state[mockNamespace].deploymentData = mockDeploymentData;
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findTimeChart().props().deploymentData).toEqual(mockDeploymentData);
- });
- });
-
- it('handles namespaced project path state', () => {
- store.state[mockNamespace].projectPath = mockProjectPath;
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findTimeChart().props().projectPath).toBe(mockProjectPath);
- });
- });
-
- it('it renders a time series chart with no errors', () => {
- expect(wrapper.find(TimeSeriesChart).isVueInstance()).toBe(true);
- expect(wrapper.find(TimeSeriesChart).exists()).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/monitoring/components/variables/custom_variable_spec.js b/spec/frontend/monitoring/components/variables/custom_variable_spec.js
new file mode 100644
index 00000000000..5a2b26219b6
--- /dev/null
+++ b/spec/frontend/monitoring/components/variables/custom_variable_spec.js
@@ -0,0 +1,52 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import CustomVariable from '~/monitoring/components/variables/custom_variable.vue';
+
+describe('Custom variable component', () => {
+ let wrapper;
+ const propsData = {
+ name: 'env',
+ label: 'Select environment',
+ value: 'Production',
+ options: [{ text: 'Production', value: 'prod' }, { text: 'Canary', value: 'canary' }],
+ };
+ const createShallowWrapper = () => {
+ wrapper = shallowMount(CustomVariable, {
+ propsData,
+ });
+ };
+
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
+
+ it('renders dropdown element when all necessary props are passed', () => {
+ createShallowWrapper();
+
+ expect(findDropdown()).toExist();
+ });
+
+ it('renders dropdown element with a text', () => {
+ createShallowWrapper();
+
+ expect(findDropdown().attributes('text')).toBe(propsData.value);
+ });
+
+ it('renders all the dropdown items', () => {
+ createShallowWrapper();
+
+ expect(findDropdownItems()).toHaveLength(propsData.options.length);
+ });
+
+ it('changing dropdown items triggers update', () => {
+ createShallowWrapper();
+ jest.spyOn(wrapper.vm, '$emit');
+
+ findDropdownItems()
+ .at(1)
+ .vm.$emit('click');
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'env', 'canary');
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/components/variables/text_variable_spec.js b/spec/frontend/monitoring/components/variables/text_variable_spec.js
new file mode 100644
index 00000000000..f01584ae8bc
--- /dev/null
+++ b/spec/frontend/monitoring/components/variables/text_variable_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlFormInput } from '@gitlab/ui';
+import TextVariable from '~/monitoring/components/variables/text_variable.vue';
+
+describe('Text variable component', () => {
+ let wrapper;
+ const propsData = {
+ name: 'pod',
+ label: 'Select pod',
+ value: 'test-pod',
+ };
+ const createShallowWrapper = () => {
+ wrapper = shallowMount(TextVariable, {
+ propsData,
+ });
+ };
+
+ const findInput = () => wrapper.find(GlFormInput);
+
+ it('renders a text input when all props are passed', () => {
+ createShallowWrapper();
+
+ expect(findInput()).toExist();
+ });
+
+ it('always has a default value', () => {
+ createShallowWrapper();
+
+ return wrapper.vm.$nextTick(() => {
+ expect(findInput().attributes('value')).toBe(propsData.value);
+ });
+ });
+
+ it('triggers keyup enter', () => {
+ createShallowWrapper();
+ jest.spyOn(wrapper.vm, '$emit');
+
+ findInput().element.value = 'prod-pod';
+ findInput().trigger('input');
+ findInput().trigger('keyup.enter');
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'pod', 'prod-pod');
+ });
+ });
+
+ it('triggers blur enter', () => {
+ createShallowWrapper();
+ jest.spyOn(wrapper.vm, '$emit');
+
+ findInput().element.value = 'canary-pod';
+ findInput().trigger('input');
+ findInput().trigger('blur');
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'pod', 'canary-pod');
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/components/variables_section_spec.js b/spec/frontend/monitoring/components/variables_section_spec.js
new file mode 100644
index 00000000000..095d89c9231
--- /dev/null
+++ b/spec/frontend/monitoring/components/variables_section_spec.js
@@ -0,0 +1,126 @@
+import { shallowMount } from '@vue/test-utils';
+import Vuex from 'vuex';
+import VariablesSection from '~/monitoring/components/variables_section.vue';
+import CustomVariable from '~/monitoring/components/variables/custom_variable.vue';
+import TextVariable from '~/monitoring/components/variables/text_variable.vue';
+import { updateHistory, mergeUrlParams } from '~/lib/utils/url_utility';
+import { createStore } from '~/monitoring/stores';
+import { convertVariablesForURL } from '~/monitoring/utils';
+import * as types from '~/monitoring/stores/mutation_types';
+import { mockTemplatingDataResponses } from '../mock_data';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ updateHistory: jest.fn(),
+ mergeUrlParams: jest.fn(),
+}));
+
+describe('Metrics dashboard/variables section component', () => {
+ let store;
+ let wrapper;
+ const sampleVariables = {
+ label1: mockTemplatingDataResponses.simpleText.simpleText,
+ label2: mockTemplatingDataResponses.advText.advText,
+ label3: mockTemplatingDataResponses.simpleCustom.simpleCustom,
+ };
+
+ const createShallowWrapper = () => {
+ wrapper = shallowMount(VariablesSection, {
+ store,
+ });
+ };
+
+ const findTextInput = () => wrapper.findAll(TextVariable);
+ const findCustomInput = () => wrapper.findAll(CustomVariable);
+
+ beforeEach(() => {
+ store = createStore();
+
+ store.state.monitoringDashboard.showEmptyState = false;
+ });
+
+ it('does not show the variables section', () => {
+ createShallowWrapper();
+ const allInputs = findTextInput().length + findCustomInput().length;
+
+ expect(allInputs).toBe(0);
+ });
+
+ it('shows the variables section', () => {
+ createShallowWrapper();
+ store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, sampleVariables);
+
+ return wrapper.vm.$nextTick(() => {
+ const allInputs = findTextInput().length + findCustomInput().length;
+
+ expect(allInputs).toBe(Object.keys(sampleVariables).length);
+ });
+ });
+
+ describe('when changing the variable inputs', () => {
+ const fetchDashboardData = jest.fn();
+ const updateVariableValues = jest.fn();
+
+ beforeEach(() => {
+ store = new Vuex.Store({
+ modules: {
+ monitoringDashboard: {
+ namespaced: true,
+ state: {
+ showEmptyState: false,
+ promVariables: sampleVariables,
+ },
+ actions: {
+ fetchDashboardData,
+ updateVariableValues,
+ },
+ },
+ },
+ });
+
+ createShallowWrapper();
+ });
+
+ it('merges the url params and refreshes the dashboard when a text-based variables inputs are updated', () => {
+ const firstInput = findTextInput().at(0);
+
+ firstInput.vm.$emit('onUpdate', 'label1', 'test');
+
+ return wrapper.vm.$nextTick(() => {
+ expect(updateVariableValues).toHaveBeenCalled();
+ expect(mergeUrlParams).toHaveBeenCalledWith(
+ convertVariablesForURL(sampleVariables),
+ window.location.href,
+ );
+ expect(updateHistory).toHaveBeenCalled();
+ expect(fetchDashboardData).toHaveBeenCalled();
+ });
+ });
+
+ it('merges the url params and refreshes the dashboard when a custom-based variables inputs are updated', () => {
+ const firstInput = findCustomInput().at(0);
+
+ firstInput.vm.$emit('onUpdate', 'label1', 'test');
+
+ return wrapper.vm.$nextTick(() => {
+ expect(updateVariableValues).toHaveBeenCalled();
+ expect(mergeUrlParams).toHaveBeenCalledWith(
+ convertVariablesForURL(sampleVariables),
+ window.location.href,
+ );
+ expect(updateHistory).toHaveBeenCalled();
+ expect(fetchDashboardData).toHaveBeenCalled();
+ });
+ });
+
+ it('does not merge the url params and refreshes the dashboard if the value entered is not different that is what currently stored', () => {
+ const firstInput = findTextInput().at(0);
+
+ firstInput.vm.$emit('onUpdate', 'label1', 'Simple text');
+
+ expect(updateVariableValues).not.toHaveBeenCalled();
+ expect(mergeUrlParams).not.toHaveBeenCalled();
+ expect(updateHistory).not.toHaveBeenCalled();
+ expect(fetchDashboardData).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js
index 56236918c68..4611e6f1b18 100644
--- a/spec/frontend/monitoring/mock_data.js
+++ b/spec/frontend/monitoring/mock_data.js
@@ -34,6 +34,7 @@ const customDashboardsData = new Array(30).fill(null).map((_, idx) => ({
system_dashboard: false,
project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_${idx}.yml`,
path: `.gitlab/dashboards/dashboard_${idx}.yml`,
+ starred: false,
}));
export const mockDashboardsErrorResponse = {
@@ -323,6 +324,18 @@ export const dashboardGitResponse = [
system_dashboard: true,
project_blob_path: null,
path: 'config/prometheus/common_metrics.yml',
+ starred: false,
+ user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=config/prometheus/common_metrics.yml`,
+ },
+ {
+ default: false,
+ display_name: 'dashboard.yml',
+ can_edit: true,
+ system_dashboard: false,
+ project_blob_path: `${mockProjectDir}/-/blob/master/.gitlab/dashboards/dashboard.yml`,
+ path: '.gitlab/dashboards/dashboard.yml',
+ starred: true,
+ user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=.gitlab/dashboards/dashboard.yml`,
},
...customDashboardsData,
];
@@ -341,7 +354,7 @@ export const metricsResult = [
},
];
-export const graphDataPrometheusQuery = {
+export const singleStatMetricsResult = {
title: 'Super Chart A2',
type: 'single-stat',
weight: 2,
@@ -489,7 +502,7 @@ export const stackedColumnMockedData = {
export const barMockData = {
title: 'SLA Trends - Primary Services',
- type: 'bar-chart',
+ type: 'bar',
xLabel: 'service',
y_label: 'percentile',
metrics: [
@@ -549,3 +562,217 @@ export const mockNamespacedData = {
export const mockLogsPath = '/mockLogsPath';
export const mockLogsHref = `${mockLogsPath}?duration_seconds=${mockTimeRange.duration.seconds}`;
+
+const templatingVariableTypes = {
+ text: {
+ simple: 'Simple text',
+ advanced: {
+ label: 'Variable 4',
+ type: 'text',
+ options: {
+ default_value: 'default',
+ },
+ },
+ },
+ custom: {
+ simple: ['value1', 'value2', 'value3'],
+ advanced: {
+ normal: {
+ label: 'Advanced Var',
+ type: 'custom',
+ options: {
+ values: [
+ { value: 'value1', text: 'Var 1 Option 1' },
+ {
+ value: 'value2',
+ text: 'Var 1 Option 2',
+ default: true,
+ },
+ ],
+ },
+ },
+ withoutOpts: {
+ type: 'custom',
+ options: {},
+ },
+ withoutLabel: {
+ type: 'custom',
+ options: {
+ values: [
+ { value: 'value1', text: 'Var 1 Option 1' },
+ {
+ value: 'value2',
+ text: 'Var 1 Option 2',
+ default: true,
+ },
+ ],
+ },
+ },
+ withoutType: {
+ label: 'Variable 2',
+ options: {
+ values: [
+ { value: 'value1', text: 'Var 1 Option 1' },
+ {
+ value: 'value2',
+ text: 'Var 1 Option 2',
+ default: true,
+ },
+ ],
+ },
+ },
+ },
+ },
+};
+
+const generateMockTemplatingData = data => {
+ const vars = data
+ ? {
+ variables: {
+ ...data,
+ },
+ }
+ : {};
+ return {
+ dashboard: {
+ templating: vars,
+ },
+ };
+};
+
+const responseForSimpleTextVariable = {
+ simpleText: {
+ label: 'simpleText',
+ type: 'text',
+ value: 'Simple text',
+ },
+};
+
+const responseForAdvTextVariable = {
+ advText: {
+ label: 'Variable 4',
+ type: 'text',
+ value: 'default',
+ },
+};
+
+const responseForSimpleCustomVariable = {
+ simpleCustom: {
+ label: 'simpleCustom',
+ value: 'value1',
+ options: [
+ {
+ default: false,
+ text: 'value1',
+ value: 'value1',
+ },
+ {
+ default: false,
+ text: 'value2',
+ value: 'value2',
+ },
+ {
+ default: false,
+ text: 'value3',
+ value: 'value3',
+ },
+ ],
+ type: 'custom',
+ },
+};
+
+const responseForAdvancedCustomVariableWithoutOptions = {
+ advCustomWithoutOpts: {
+ label: 'advCustomWithoutOpts',
+ options: [],
+ type: 'custom',
+ },
+};
+
+const responseForAdvancedCustomVariableWithoutLabel = {
+ advCustomWithoutLabel: {
+ label: 'advCustomWithoutLabel',
+ value: 'value2',
+ options: [
+ {
+ default: false,
+ text: 'Var 1 Option 1',
+ value: 'value1',
+ },
+ {
+ default: true,
+ text: 'Var 1 Option 2',
+ value: 'value2',
+ },
+ ],
+ type: 'custom',
+ },
+};
+
+const responseForAdvancedCustomVariable = {
+ ...responseForSimpleCustomVariable,
+ advCustomNormal: {
+ label: 'Advanced Var',
+ value: 'value2',
+ options: [
+ {
+ default: false,
+ text: 'Var 1 Option 1',
+ value: 'value1',
+ },
+ {
+ default: true,
+ text: 'Var 1 Option 2',
+ value: 'value2',
+ },
+ ],
+ type: 'custom',
+ },
+};
+
+const responsesForAllVariableTypes = {
+ ...responseForSimpleTextVariable,
+ ...responseForAdvTextVariable,
+ ...responseForSimpleCustomVariable,
+ ...responseForAdvancedCustomVariable,
+};
+
+export const mockTemplatingData = {
+ emptyTemplatingProp: generateMockTemplatingData(),
+ emptyVariablesProp: generateMockTemplatingData({}),
+ simpleText: generateMockTemplatingData({ simpleText: templatingVariableTypes.text.simple }),
+ advText: generateMockTemplatingData({ advText: templatingVariableTypes.text.advanced }),
+ simpleCustom: generateMockTemplatingData({ simpleCustom: templatingVariableTypes.custom.simple }),
+ advCustomWithoutOpts: generateMockTemplatingData({
+ advCustomWithoutOpts: templatingVariableTypes.custom.advanced.withoutOpts,
+ }),
+ advCustomWithoutType: generateMockTemplatingData({
+ advCustomWithoutType: templatingVariableTypes.custom.advanced.withoutType,
+ }),
+ advCustomWithoutLabel: generateMockTemplatingData({
+ advCustomWithoutLabel: templatingVariableTypes.custom.advanced.withoutLabel,
+ }),
+ simpleAndAdv: generateMockTemplatingData({
+ simpleCustom: templatingVariableTypes.custom.simple,
+ advCustomNormal: templatingVariableTypes.custom.advanced.normal,
+ }),
+ allVariableTypes: generateMockTemplatingData({
+ simpleText: templatingVariableTypes.text.simple,
+ advText: templatingVariableTypes.text.advanced,
+ simpleCustom: templatingVariableTypes.custom.simple,
+ advCustomNormal: templatingVariableTypes.custom.advanced.normal,
+ }),
+};
+
+export const mockTemplatingDataResponses = {
+ emptyTemplatingProp: {},
+ emptyVariablesProp: {},
+ simpleText: responseForSimpleTextVariable,
+ advText: responseForAdvTextVariable,
+ simpleCustom: responseForSimpleCustomVariable,
+ advCustomWithoutOpts: responseForAdvancedCustomVariableWithoutOptions,
+ advCustomWithoutType: {},
+ advCustomWithoutLabel: responseForAdvancedCustomVariableWithoutLabel,
+ simpleAndAdv: responseForAdvancedCustomVariable,
+ allVariableTypes: responsesForAllVariableTypes,
+};
diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js
index f312aa1fd34..8914f2e66ea 100644
--- a/spec/frontend/monitoring/store/actions_spec.js
+++ b/spec/frontend/monitoring/store/actions_spec.js
@@ -11,17 +11,22 @@ import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants';
import store from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types';
import {
+ fetchData,
fetchDashboard,
receiveMetricsDashboardSuccess,
fetchDeploymentsData,
fetchEnvironmentsData,
fetchDashboardData,
fetchAnnotations,
+ toggleStarredValue,
fetchPrometheusMetric,
setInitialState,
filterEnvironments,
+ setExpandedPanel,
+ clearExpandedPanel,
setGettingStartedEmptyState,
duplicateSystemDashboard,
+ updateVariableValues,
} from '~/monitoring/stores/actions';
import {
gqClient,
@@ -35,6 +40,7 @@ import {
deploymentData,
environmentData,
annotationsData,
+ mockTemplatingData,
dashboardGitResponse,
mockDashboardsErrorResponse,
} from '../mock_data';
@@ -62,9 +68,6 @@ describe('Monitoring store actions', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
- // Mock `backOff` function to remove exponential algorithm delay.
- jest.useFakeTimers();
-
jest.spyOn(commonUtils, 'backOff').mockImplementation(callback => {
const q = new Promise((resolve, reject) => {
const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg));
@@ -87,6 +90,45 @@ describe('Monitoring store actions', () => {
createFlash.mockReset();
});
+ describe('fetchData', () => {
+ it('dispatches fetchEnvironmentsData and fetchEnvironmentsData', () => {
+ const { state } = store;
+
+ return testAction(
+ fetchData,
+ null,
+ state,
+ [],
+ [
+ { type: 'fetchEnvironmentsData' },
+ { type: 'fetchDashboard' },
+ { type: 'fetchAnnotations' },
+ ],
+ );
+ });
+
+ it('dispatches when feature metricsDashboardAnnotations is on', () => {
+ const origGon = window.gon;
+ window.gon = { features: { metricsDashboardAnnotations: true } };
+
+ const { state } = store;
+
+ return testAction(
+ fetchData,
+ null,
+ state,
+ [],
+ [
+ { type: 'fetchEnvironmentsData' },
+ { type: 'fetchDashboard' },
+ { type: 'fetchAnnotations' },
+ ],
+ ).then(() => {
+ window.gon = origGon;
+ });
+ });
+ });
+
describe('fetchDeploymentsData', () => {
it('dispatches receiveDeploymentsDataSuccess on success', () => {
const { state } = store;
@@ -310,6 +352,49 @@ describe('Monitoring store actions', () => {
});
});
+ describe('Toggles starred value of current dashboard', () => {
+ const { state } = store;
+ let unstarredDashboard;
+ let starredDashboard;
+
+ beforeEach(() => {
+ state.isUpdatingStarredValue = false;
+ [unstarredDashboard, starredDashboard] = dashboardGitResponse;
+ });
+
+ describe('toggleStarredValue', () => {
+ it('performs no changes if no dashboard is selected', () => {
+ return testAction(toggleStarredValue, null, state, [], []);
+ });
+
+ it('performs no changes if already changing starred value', () => {
+ state.selectedDashboard = unstarredDashboard;
+ state.isUpdatingStarredValue = true;
+ return testAction(toggleStarredValue, null, state, [], []);
+ });
+
+ it('stars dashboard if it is not starred', () => {
+ state.selectedDashboard = unstarredDashboard;
+ mock.onPost(unstarredDashboard.user_starred_path).reply(200);
+
+ return testAction(toggleStarredValue, null, state, [
+ { type: types.REQUEST_DASHBOARD_STARRING },
+ { type: types.RECEIVE_DASHBOARD_STARRING_SUCCESS, payload: true },
+ ]);
+ });
+
+ it('unstars dashboard if it is starred', () => {
+ state.selectedDashboard = starredDashboard;
+ mock.onPost(starredDashboard.user_starred_path).reply(200);
+
+ return testAction(toggleStarredValue, null, state, [
+ { type: types.REQUEST_DASHBOARD_STARRING },
+ { type: types.RECEIVE_DASHBOARD_STARRING_FAILURE },
+ ]);
+ });
+ });
+ });
+
describe('Set initial state', () => {
let mockedState;
beforeEach(() => {
@@ -357,6 +442,29 @@ describe('Monitoring store actions', () => {
);
});
});
+
+ describe('updateVariableValues', () => {
+ let mockedState;
+ beforeEach(() => {
+ mockedState = storeState();
+ });
+ it('should commit UPDATE_VARIABLE_VALUES mutation', done => {
+ testAction(
+ updateVariableValues,
+ { pod: 'POD' },
+ mockedState,
+ [
+ {
+ type: types.UPDATE_VARIABLE_VALUES,
+ payload: { pod: 'POD' },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
describe('fetchDashboard', () => {
let dispatch;
let state;
@@ -467,6 +575,33 @@ describe('Monitoring store actions', () => {
);
expect(dispatch).toHaveBeenCalledWith('fetchDashboardData');
});
+
+ it('stores templating variables', () => {
+ const response = {
+ ...metricsDashboardResponse.dashboard,
+ ...mockTemplatingData.allVariableTypes.dashboard,
+ };
+
+ receiveMetricsDashboardSuccess(
+ { state, commit, dispatch },
+ {
+ response: {
+ ...metricsDashboardResponse,
+ dashboard: {
+ ...metricsDashboardResponse.dashboard,
+ ...mockTemplatingData.allVariableTypes.dashboard,
+ },
+ },
+ },
+ );
+
+ expect(commit).toHaveBeenCalledWith(
+ types.RECEIVE_METRICS_DASHBOARD_SUCCESS,
+
+ response,
+ );
+ });
+
it('sets the dashboards loaded from the repository', () => {
const params = {};
const response = metricsDashboardResponse;
@@ -873,4 +1008,43 @@ describe('Monitoring store actions', () => {
});
});
});
+
+ describe('setExpandedPanel', () => {
+ let state;
+
+ beforeEach(() => {
+ state = storeState();
+ });
+
+ it('Sets a panel as expanded', () => {
+ const group = 'group_1';
+ const panel = { title: 'A Panel' };
+
+ return testAction(
+ setExpandedPanel,
+ { group, panel },
+ state,
+ [{ type: types.SET_EXPANDED_PANEL, payload: { group, panel } }],
+ [],
+ );
+ });
+ });
+
+ describe('clearExpandedPanel', () => {
+ let state;
+
+ beforeEach(() => {
+ state = storeState();
+ });
+
+ it('Clears a panel as expanded', () => {
+ return testAction(
+ clearExpandedPanel,
+ undefined,
+ state,
+ [{ type: types.SET_EXPANDED_PANEL, payload: { group: null, panel: null } }],
+ [],
+ );
+ });
+ });
});
diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js
index f040876b832..365052e68e3 100644
--- a/spec/frontend/monitoring/store/getters_spec.js
+++ b/spec/frontend/monitoring/store/getters_spec.js
@@ -3,7 +3,12 @@ import * as getters from '~/monitoring/stores/getters';
import mutations from '~/monitoring/stores/mutations';
import * as types from '~/monitoring/stores/mutation_types';
import { metricStates } from '~/monitoring/constants';
-import { environmentData, metricsResult } from '../mock_data';
+import {
+ environmentData,
+ metricsResult,
+ dashboardGitResponse,
+ mockTemplatingDataResponses,
+} from '../mock_data';
import {
metricsDashboardPayload,
metricResultStatus,
@@ -323,4 +328,81 @@ describe('Monitoring store Getters', () => {
expect(metricsSavedToDb).toEqual([`${id1}_${metric1.id}`, `${id2}_${metric2.id}`]);
});
});
+
+ describe('getCustomVariablesArray', () => {
+ let state;
+
+ beforeEach(() => {
+ state = {
+ promVariables: {},
+ };
+ });
+
+ it('transforms the promVariables object to an array in the [variable, variable_value] format for all variable types', () => {
+ mutations[types.SET_VARIABLES](state, mockTemplatingDataResponses.allVariableTypes);
+ const variablesArray = getters.getCustomVariablesArray(state);
+
+ expect(variablesArray).toEqual([
+ 'simpleText',
+ 'Simple text',
+ 'advText',
+ 'default',
+ 'simpleCustom',
+ 'value1',
+ 'advCustomNormal',
+ 'value2',
+ ]);
+ });
+
+ it('transforms the promVariables object to an empty array when no keys are present', () => {
+ mutations[types.SET_VARIABLES](state, {});
+ const variablesArray = getters.getCustomVariablesArray(state);
+
+ expect(variablesArray).toEqual([]);
+ });
+ });
+
+ describe('selectedDashboard', () => {
+ const { selectedDashboard } = getters;
+
+ it('returns a dashboard', () => {
+ const state = {
+ allDashboards: dashboardGitResponse,
+ currentDashboard: dashboardGitResponse[0].path,
+ };
+ expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]);
+ });
+
+ it('returns a non-default dashboard', () => {
+ const state = {
+ allDashboards: dashboardGitResponse,
+ currentDashboard: dashboardGitResponse[1].path,
+ };
+ expect(selectedDashboard(state)).toEqual(dashboardGitResponse[1]);
+ });
+
+ it('returns a default dashboard when no dashboard is selected', () => {
+ const state = {
+ allDashboards: dashboardGitResponse,
+ currentDashboard: null,
+ };
+ expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]);
+ });
+
+ it('returns a default dashboard when dashboard cannot be found', () => {
+ const state = {
+ allDashboards: dashboardGitResponse,
+ currentDashboard: 'wrong_path',
+ };
+ expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]);
+ });
+
+ it('returns null when no dashboards are present', () => {
+ const state = {
+ allDashboards: [],
+ currentDashboard: dashboardGitResponse[0].path,
+ };
+ expect(selectedDashboard(state)).toEqual(null);
+ });
+ });
});
diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js
index 1452e9bc491..4306243689a 100644
--- a/spec/frontend/monitoring/store/mutations_spec.js
+++ b/spec/frontend/monitoring/store/mutations_spec.js
@@ -72,6 +72,49 @@ describe('Monitoring mutations', () => {
});
});
+ describe('Dashboard starring mutations', () => {
+ it('REQUEST_DASHBOARD_STARRING', () => {
+ stateCopy = { isUpdatingStarredValue: false };
+ mutations[types.REQUEST_DASHBOARD_STARRING](stateCopy);
+
+ expect(stateCopy.isUpdatingStarredValue).toBe(true);
+ });
+
+ describe('RECEIVE_DASHBOARD_STARRING_SUCCESS', () => {
+ let allDashboards;
+
+ beforeEach(() => {
+ allDashboards = [...dashboardGitResponse];
+ stateCopy = {
+ allDashboards,
+ currentDashboard: allDashboards[1].path,
+ isUpdatingStarredValue: true,
+ };
+ });
+
+ it('sets a dashboard as starred', () => {
+ mutations[types.RECEIVE_DASHBOARD_STARRING_SUCCESS](stateCopy, true);
+
+ expect(stateCopy.isUpdatingStarredValue).toBe(false);
+ expect(stateCopy.allDashboards[1].starred).toBe(true);
+ });
+
+ it('sets a dashboard as unstarred', () => {
+ mutations[types.RECEIVE_DASHBOARD_STARRING_SUCCESS](stateCopy, false);
+
+ expect(stateCopy.isUpdatingStarredValue).toBe(false);
+ expect(stateCopy.allDashboards[1].starred).toBe(false);
+ });
+ });
+
+ it('RECEIVE_DASHBOARD_STARRING_FAILURE', () => {
+ stateCopy = { isUpdatingStarredValue: true };
+ mutations[types.RECEIVE_DASHBOARD_STARRING_FAILURE](stateCopy);
+
+ expect(stateCopy.isUpdatingStarredValue).toBe(false);
+ });
+ });
+
describe('RECEIVE_DEPLOYMENTS_DATA_SUCCESS', () => {
it('stores the deployment data', () => {
stateCopy.deploymentData = [];
@@ -342,4 +385,53 @@ describe('Monitoring mutations', () => {
expect(stateCopy.allDashboards).toEqual(dashboardGitResponse);
});
});
+
+ describe('SET_EXPANDED_PANEL', () => {
+ it('no expanded panel is set initally', () => {
+ expect(stateCopy.expandedPanel.panel).toEqual(null);
+ expect(stateCopy.expandedPanel.group).toEqual(null);
+ });
+
+ it('sets a panel id as the expanded panel', () => {
+ const group = 'group_1';
+ const panel = { title: 'A Panel' };
+ mutations[types.SET_EXPANDED_PANEL](stateCopy, { group, panel });
+
+ expect(stateCopy.expandedPanel).toEqual({ group, panel });
+ });
+
+ it('clears panel as the expanded panel', () => {
+ mutations[types.SET_EXPANDED_PANEL](stateCopy, { group: null, panel: null });
+
+ expect(stateCopy.expandedPanel.group).toEqual(null);
+ expect(stateCopy.expandedPanel.panel).toEqual(null);
+ });
+ });
+
+ describe('SET_VARIABLES', () => {
+ it('stores an empty variables array when no custom variables are given', () => {
+ mutations[types.SET_VARIABLES](stateCopy, {});
+
+ expect(stateCopy.promVariables).toEqual({});
+ });
+
+ it('stores variables in the key key_value format in the array', () => {
+ mutations[types.SET_VARIABLES](stateCopy, { pod: 'POD', stage: 'main ops' });
+
+ expect(stateCopy.promVariables).toEqual({ pod: 'POD', stage: 'main ops' });
+ });
+ });
+
+ describe('UPDATE_VARIABLE_VALUES', () => {
+ afterEach(() => {
+ mutations[types.SET_VARIABLES](stateCopy, {});
+ });
+
+ it('updates only the value of the variable in promVariables', () => {
+ mutations[types.SET_VARIABLES](stateCopy, { environment: { value: 'prod', type: 'text' } });
+ mutations[types.UPDATE_VARIABLE_VALUES](stateCopy, { key: 'environment', value: 'new prod' });
+
+ expect(stateCopy.promVariables).toEqual({ environment: { value: 'new prod', type: 'text' } });
+ });
+ });
});
diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js
index 7ee2a16b4bd..fe5754e1216 100644
--- a/spec/frontend/monitoring/store/utils_spec.js
+++ b/spec/frontend/monitoring/store/utils_spec.js
@@ -27,6 +27,7 @@ describe('mapToDashboardViewModel', () => {
group: 'Group 1',
panels: [
{
+ id: 'ID_ABC',
title: 'Title A',
xLabel: '',
xAxis: {
@@ -49,6 +50,7 @@ describe('mapToDashboardViewModel', () => {
key: 'group-1-0',
panels: [
{
+ id: 'ID_ABC',
title: 'Title A',
type: 'chart-type',
xLabel: '',
@@ -127,11 +129,13 @@ describe('mapToDashboardViewModel', () => {
it('panel with x_label', () => {
setupWithPanel({
+ id: 'ID_123',
title: panelTitle,
x_label: 'x label',
});
expect(getMappedPanel()).toEqual({
+ id: 'ID_123',
title: panelTitle,
xLabel: 'x label',
xAxis: {
@@ -149,10 +153,12 @@ describe('mapToDashboardViewModel', () => {
it('group y_axis defaults', () => {
setupWithPanel({
+ id: 'ID_456',
title: panelTitle,
});
expect(getMappedPanel()).toEqual({
+ id: 'ID_456',
title: panelTitle,
xLabel: '',
y_label: '',
diff --git a/spec/frontend/monitoring/store/variable_mapping_spec.js b/spec/frontend/monitoring/store/variable_mapping_spec.js
new file mode 100644
index 00000000000..47681ac7c65
--- /dev/null
+++ b/spec/frontend/monitoring/store/variable_mapping_spec.js
@@ -0,0 +1,22 @@
+import { parseTemplatingVariables } from '~/monitoring/stores/variable_mapping';
+import { mockTemplatingData, mockTemplatingDataResponses } from '../mock_data';
+
+describe('parseTemplatingVariables', () => {
+ it.each`
+ case | input | expected
+ ${'Returns empty object for no dashboard input'} | ${{}} | ${{}}
+ ${'Returns empty object for empty dashboard input'} | ${{ dashboard: {} }} | ${{}}
+ ${'Returns empty object for empty templating prop'} | ${mockTemplatingData.emptyTemplatingProp} | ${{}}
+ ${'Returns empty object for empty variables prop'} | ${mockTemplatingData.emptyVariablesProp} | ${{}}
+ ${'Returns parsed object for simple text variable'} | ${mockTemplatingData.simpleText} | ${mockTemplatingDataResponses.simpleText}
+ ${'Returns parsed object for advanced text variable'} | ${mockTemplatingData.advText} | ${mockTemplatingDataResponses.advText}
+ ${'Returns parsed object for simple custom variable'} | ${mockTemplatingData.simpleCustom} | ${mockTemplatingDataResponses.simpleCustom}
+ ${'Returns parsed object for advanced custom variable without options'} | ${mockTemplatingData.advCustomWithoutOpts} | ${mockTemplatingDataResponses.advCustomWithoutOpts}
+ ${'Returns parsed object for advanced custom variable without type'} | ${mockTemplatingData.advCustomWithoutType} | ${{}}
+ ${'Returns parsed object for advanced custom variable without label'} | ${mockTemplatingData.advCustomWithoutLabel} | ${mockTemplatingDataResponses.advCustomWithoutLabel}
+ ${'Returns parsed object for simple and advanced custom variables'} | ${mockTemplatingData.simpleAndAdv} | ${mockTemplatingDataResponses.simpleAndAdv}
+ ${'Returns parsed object for all variable types'} | ${mockTemplatingData.allVariableTypes} | ${mockTemplatingDataResponses.allVariableTypes}
+ `('$case', ({ input, expected }) => {
+ expect(parseTemplatingVariables(input?.dashboard?.templating)).toEqual(expected);
+ });
+});
diff --git a/spec/frontend/monitoring/store_utils.js b/spec/frontend/monitoring/store_utils.js
index d764a79ccc3..338af79dbbe 100644
--- a/spec/frontend/monitoring/store_utils.js
+++ b/spec/frontend/monitoring/store_utils.js
@@ -1,34 +1,49 @@
import * as types from '~/monitoring/stores/mutation_types';
-import { metricsResult, environmentData } from './mock_data';
+import { metricsResult, environmentData, dashboardGitResponse } from './mock_data';
import { metricsDashboardPayload } from './fixture_data';
-export const setMetricResult = ({ $store, result, group = 0, panel = 0, metric = 0 }) => {
- const { dashboard } = $store.state.monitoringDashboard;
+export const setMetricResult = ({ store, result, group = 0, panel = 0, metric = 0 }) => {
+ const { dashboard } = store.state.monitoringDashboard;
const { metricId } = dashboard.panelGroups[group].panels[panel].metrics[metric];
- $store.commit(`monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, {
+ store.commit(`monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, {
metricId,
result,
});
};
-const setEnvironmentData = $store => {
- $store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData);
+const setEnvironmentData = store => {
+ store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData);
};
-export const setupStoreWithDashboard = $store => {
- $store.commit(
+export const setupAllDashboards = store => {
+ store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, dashboardGitResponse);
+};
+
+export const setupStoreWithDashboard = store => {
+ store.commit(
+ `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`,
+ metricsDashboardPayload,
+ );
+ store.commit(
`monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`,
metricsDashboardPayload,
);
};
-export const setupStoreWithData = $store => {
- setupStoreWithDashboard($store);
+export const setupStoreWithVariable = store => {
+ store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, {
+ label1: 'pod',
+ });
+};
+
+export const setupStoreWithData = store => {
+ setupAllDashboards(store);
+ setupStoreWithDashboard(store);
- setMetricResult({ $store, result: [], panel: 0 });
- setMetricResult({ $store, result: metricsResult, panel: 1 });
- setMetricResult({ $store, result: metricsResult, panel: 2 });
+ setMetricResult({ store, result: [], panel: 0 });
+ setMetricResult({ store, result: metricsResult, panel: 1 });
+ setMetricResult({ store, result: metricsResult, panel: 2 });
- setEnvironmentData($store);
+ setEnvironmentData(store);
};
diff --git a/spec/frontend/monitoring/stubs/modal_stub.js b/spec/frontend/monitoring/stubs/modal_stub.js
new file mode 100644
index 00000000000..4cd0362096e
--- /dev/null
+++ b/spec/frontend/monitoring/stubs/modal_stub.js
@@ -0,0 +1,11 @@
+const ModalStub = {
+ name: 'glmodal-stub',
+ template: `
+ <div>
+ <slot></slot>
+ <slot name="modal-ok"></slot>
+ </div>
+ `,
+};
+
+export default ModalStub;
diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js
index 0bb1b987b2e..aa5a4459a72 100644
--- a/spec/frontend/monitoring/utils_spec.js
+++ b/spec/frontend/monitoring/utils_spec.js
@@ -1,15 +1,13 @@
import * as monitoringUtils from '~/monitoring/utils';
-import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
+import * as urlUtils from '~/lib/utils/url_utility';
import { TEST_HOST } from 'jest/helpers/test_constants';
import {
mockProjectDir,
- graphDataPrometheusQuery,
+ singleStatMetricsResult,
anomalyMockGraphData,
barMockData,
} from './mock_data';
-import { graphData } from './fixture_data';
-
-jest.mock('~/lib/utils/url_utility');
+import { metricsDashboardViewModel, graphData } from './fixture_data';
const mockPath = `${TEST_HOST}${mockProjectDir}/-/environments/29/metrics`;
@@ -27,11 +25,6 @@ const rollingRange = {
};
describe('monitoring/utils', () => {
- afterEach(() => {
- mergeUrlParams.mockReset();
- queryToObject.mockReset();
- });
-
describe('trackGenerateLinkToChartEventOptions', () => {
it('should return Cluster Monitoring options if located on Cluster Health Dashboard', () => {
document.body.dataset.page = 'groups:clusters:show';
@@ -89,7 +82,7 @@ describe('monitoring/utils', () => {
it('validates data with the query format', () => {
const validGraphData = monitoringUtils.graphDataValidatorForValues(
true,
- graphDataPrometheusQuery,
+ singleStatMetricsResult,
);
expect(validGraphData).toBe(true);
@@ -112,7 +105,7 @@ describe('monitoring/utils', () => {
let threeMetrics;
let fourMetrics;
beforeEach(() => {
- oneMetric = graphDataPrometheusQuery;
+ oneMetric = singleStatMetricsResult;
threeMetrics = anomalyMockGraphData;
const metrics = [...threeMetrics.metrics];
@@ -139,18 +132,25 @@ describe('monitoring/utils', () => {
});
describe('timeRangeFromUrl', () => {
- const { timeRangeFromUrl } = monitoringUtils;
+ beforeEach(() => {
+ jest.spyOn(urlUtils, 'queryToObject');
+ });
+
+ afterEach(() => {
+ urlUtils.queryToObject.mockRestore();
+ });
- it('returns a fixed range when query contains `start` and `end` paramters are given', () => {
- queryToObject.mockReturnValueOnce(range);
+ const { timeRangeFromUrl } = monitoringUtils;
+ it('returns a fixed range when query contains `start` and `end` parameters are given', () => {
+ urlUtils.queryToObject.mockReturnValueOnce(range);
expect(timeRangeFromUrl()).toEqual(range);
});
- it('returns a rolling range when query contains `duration_seconds` paramters are given', () => {
+ it('returns a rolling range when query contains `duration_seconds` parameters are given', () => {
const { seconds } = rollingRange.duration;
- queryToObject.mockReturnValueOnce({
+ urlUtils.queryToObject.mockReturnValueOnce({
dashboard: '.gitlab/dashboard/my_dashboard.yml',
duration_seconds: `${seconds}`,
});
@@ -158,23 +158,59 @@ describe('monitoring/utils', () => {
expect(timeRangeFromUrl()).toEqual(rollingRange);
});
- it('returns null when no time range paramters are given', () => {
- const params = {
+ it('returns null when no time range parameters are given', () => {
+ urlUtils.queryToObject.mockReturnValueOnce({
dashboard: '.gitlab/dashboards/custom_dashboard.yml',
param1: 'value1',
param2: 'value2',
- };
+ });
- expect(timeRangeFromUrl(params, mockPath)).toBe(null);
+ expect(timeRangeFromUrl()).toBe(null);
+ });
+ });
+
+ describe('getPromCustomVariablesFromUrl', () => {
+ const { getPromCustomVariablesFromUrl } = monitoringUtils;
+
+ beforeEach(() => {
+ jest.spyOn(urlUtils, 'queryToObject');
+ });
+
+ afterEach(() => {
+ urlUtils.queryToObject.mockRestore();
+ });
+
+ it('returns an object with only the custom variables', () => {
+ urlUtils.queryToObject.mockReturnValueOnce({
+ dashboard: '.gitlab/dashboards/custom_dashboard.yml',
+ y_label: 'memory usage',
+ group: 'kubernetes',
+ title: 'Kubernetes memory total',
+ start: '2020-05-06',
+ end: '2020-05-07',
+ duration_seconds: '86400',
+ direction: 'left',
+ anchor: 'top',
+ pod: 'POD',
+ 'var-pod': 'POD',
+ });
+
+ expect(getPromCustomVariablesFromUrl()).toEqual(expect.objectContaining({ pod: 'POD' }));
+ });
+
+ it('returns an empty object when no custom variables are present', () => {
+ urlUtils.queryToObject.mockReturnValueOnce({
+ dashboard: '.gitlab/dashboards/custom_dashboard.yml',
+ });
+
+ expect(getPromCustomVariablesFromUrl()).toStrictEqual({});
});
});
describe('removeTimeRangeParams', () => {
const { removeTimeRangeParams } = monitoringUtils;
- it('returns when query contains `start` and `end` paramters are given', () => {
- removeParams.mockReturnValueOnce(mockPath);
-
+ it('returns when query contains `start` and `end` parameters are given', () => {
expect(removeTimeRangeParams(`${mockPath}?start=${range.start}&end=${range.end}`)).toEqual(
mockPath,
);
@@ -184,28 +220,126 @@ describe('monitoring/utils', () => {
describe('timeRangeToUrl', () => {
const { timeRangeToUrl } = monitoringUtils;
- it('returns a fixed range when query contains `start` and `end` paramters are given', () => {
+ beforeEach(() => {
+ jest.spyOn(urlUtils, 'mergeUrlParams');
+ jest.spyOn(urlUtils, 'removeParams');
+ });
+
+ afterEach(() => {
+ urlUtils.mergeUrlParams.mockRestore();
+ urlUtils.removeParams.mockRestore();
+ });
+
+ it('returns a fixed range when query contains `start` and `end` parameters are given', () => {
const toUrl = `${mockPath}?start=${range.start}&end=${range.end}`;
const fromUrl = mockPath;
- removeParams.mockReturnValueOnce(fromUrl);
- mergeUrlParams.mockReturnValueOnce(toUrl);
+ urlUtils.removeParams.mockReturnValueOnce(fromUrl);
+ urlUtils.mergeUrlParams.mockReturnValueOnce(toUrl);
expect(timeRangeToUrl(range)).toEqual(toUrl);
- expect(mergeUrlParams).toHaveBeenCalledWith(range, fromUrl);
+ expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(range, fromUrl);
});
- it('returns a rolling range when query contains `duration_seconds` paramters are given', () => {
+ it('returns a rolling range when query contains `duration_seconds` parameters are given', () => {
const { seconds } = rollingRange.duration;
const toUrl = `${mockPath}?duration_seconds=${seconds}`;
const fromUrl = mockPath;
- removeParams.mockReturnValueOnce(fromUrl);
- mergeUrlParams.mockReturnValueOnce(toUrl);
+ urlUtils.removeParams.mockReturnValueOnce(fromUrl);
+ urlUtils.mergeUrlParams.mockReturnValueOnce(toUrl);
expect(timeRangeToUrl(rollingRange)).toEqual(toUrl);
- expect(mergeUrlParams).toHaveBeenCalledWith({ duration_seconds: `${seconds}` }, fromUrl);
+ expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(
+ { duration_seconds: `${seconds}` },
+ fromUrl,
+ );
+ });
+ });
+
+ describe('expandedPanelPayloadFromUrl', () => {
+ const { expandedPanelPayloadFromUrl } = monitoringUtils;
+ const [panelGroup] = metricsDashboardViewModel.panelGroups;
+ const [panel] = panelGroup.panels;
+
+ const { group } = panelGroup;
+ const { title, y_label: yLabel } = panel;
+
+ it('returns payload for a panel when query parameters are given', () => {
+ const search = `?group=${group}&title=${title}&y_label=${yLabel}`;
+
+ expect(expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toEqual({
+ group: panelGroup.group,
+ panel,
+ });
+ });
+
+ it('returns null when no parameters are given', () => {
+ expect(expandedPanelPayloadFromUrl(metricsDashboardViewModel, '')).toBe(null);
+ });
+
+ it('throws an error when no group is provided', () => {
+ const search = `?title=${panel.title}&y_label=${yLabel}`;
+ expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
+ });
+
+ it('throws an error when no title is provided', () => {
+ const search = `?title=${title}&y_label=${yLabel}`;
+ expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
+ });
+
+ it('throws an error when no y_label group is provided', () => {
+ const search = `?group=${group}&title=${title}`;
+ expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
+ });
+
+ test.each`
+ group | title | yLabel | missingField
+ ${'NOT_A_GROUP'} | ${title} | ${yLabel} | ${'group'}
+ ${group} | ${'NOT_A_TITLE'} | ${yLabel} | ${'title'}
+ ${group} | ${title} | ${'NOT_A_Y_LABEL'} | ${'y_label'}
+ `('throws an error when $missingField is incorrect', params => {
+ const search = `?group=${params.group}&title=${params.title}&y_label=${params.yLabel}`;
+ expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
+ });
+ });
+
+ describe('panelToUrl', () => {
+ const { panelToUrl } = monitoringUtils;
+
+ const dashboard = 'metrics.yml';
+ const [panelGroup] = metricsDashboardViewModel.panelGroups;
+ const [panel] = panelGroup.panels;
+
+ const getUrlParams = url => urlUtils.queryToObject(url.split('?')[1]);
+
+ it('returns URL for a panel when query parameters are given', () => {
+ const params = getUrlParams(panelToUrl(dashboard, {}, panelGroup.group, panel));
+
+ expect(params).toEqual(
+ expect.objectContaining({
+ dashboard,
+ group: panelGroup.group,
+ title: panel.title,
+ y_label: panel.y_label,
+ }),
+ );
+ });
+
+ it('returns a dashboard only URL if group is missing', () => {
+ const params = getUrlParams(panelToUrl(dashboard, {}, null, panel));
+ expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml' }));
+ });
+
+ it('returns a dashboard only URL if panel is missing', () => {
+ const params = getUrlParams(panelToUrl(dashboard, {}, panelGroup.group, null));
+ expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml' }));
+ });
+
+ it('returns URL for a panel when query paramters are given including custom variables', () => {
+ const params = getUrlParams(panelToUrl(dashboard, { pod: 'pod' }, panelGroup.group, null));
+ expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml', pod: 'pod' }));
});
});
@@ -271,4 +405,108 @@ describe('monitoring/utils', () => {
});
});
});
+
+ describe('removePrefixFromLabel', () => {
+ it.each`
+ input | expected
+ ${undefined} | ${''}
+ ${null} | ${''}
+ ${''} | ${''}
+ ${' '} | ${' '}
+ ${'pod-1'} | ${'pod-1'}
+ ${'pod-var-1'} | ${'pod-var-1'}
+ ${'pod-1-var'} | ${'pod-1-var'}
+ ${'podvar--1'} | ${'podvar--1'}
+ ${'povar-d-1'} | ${'povar-d-1'}
+ ${'var-pod-1'} | ${'pod-1'}
+ ${'var-var-pod-1'} | ${'var-pod-1'}
+ ${'varvar-pod-1'} | ${'varvar-pod-1'}
+ ${'var-pod-1-var-'} | ${'pod-1-var-'}
+ `('removePrefixFromLabel returns $expected with input $input', ({ input, expected }) => {
+ expect(monitoringUtils.removePrefixFromLabel(input)).toEqual(expected);
+ });
+ });
+
+ describe('mergeURLVariables', () => {
+ beforeEach(() => {
+ jest.spyOn(urlUtils, 'queryToObject');
+ });
+
+ afterEach(() => {
+ urlUtils.queryToObject.mockRestore();
+ });
+
+ it('returns empty object if variables are not defined in yml or URL', () => {
+ urlUtils.queryToObject.mockReturnValueOnce({});
+
+ expect(monitoringUtils.mergeURLVariables({})).toEqual({});
+ });
+
+ it('returns empty object if variables are defined in URL but not in yml', () => {
+ urlUtils.queryToObject.mockReturnValueOnce({
+ 'var-env': 'one',
+ 'var-instance': 'localhost',
+ });
+
+ expect(monitoringUtils.mergeURLVariables({})).toEqual({});
+ });
+
+ it('returns yml variables if variables defined in yml but not in the URL', () => {
+ urlUtils.queryToObject.mockReturnValueOnce({});
+
+ const params = {
+ env: 'one',
+ instance: 'localhost',
+ };
+
+ expect(monitoringUtils.mergeURLVariables(params)).toEqual(params);
+ });
+
+ it('returns yml variables if variables defined in URL do not match with yml variables', () => {
+ const urlParams = {
+ 'var-env': 'one',
+ 'var-instance': 'localhost',
+ };
+ const ymlParams = {
+ pod: { value: 'one' },
+ service: { value: 'database' },
+ };
+ urlUtils.queryToObject.mockReturnValueOnce(urlParams);
+
+ expect(monitoringUtils.mergeURLVariables(ymlParams)).toEqual(ymlParams);
+ });
+
+ it('returns merged yml and URL variables if there is some match', () => {
+ const urlParams = {
+ 'var-env': 'one',
+ 'var-instance': 'localhost:8080',
+ };
+ const ymlParams = {
+ instance: { value: 'localhost' },
+ service: { value: 'database' },
+ };
+
+ const merged = {
+ instance: { value: 'localhost:8080' },
+ service: { value: 'database' },
+ };
+
+ urlUtils.queryToObject.mockReturnValueOnce(urlParams);
+
+ expect(monitoringUtils.mergeURLVariables(ymlParams)).toEqual(merged);
+ });
+ });
+
+ describe('convertVariablesForURL', () => {
+ it.each`
+ input | expected
+ ${undefined} | ${{}}
+ ${null} | ${{}}
+ ${{}} | ${{}}
+ ${{ env: { value: 'prod' } }} | ${{ 'var-env': 'prod' }}
+ ${{ 'var-env': { value: 'prod' } }} | ${{ 'var-var-env': 'prod' }}
+ `('convertVariablesForURL returns $expected with input $input', ({ input, expected }) => {
+ expect(monitoringUtils.convertVariablesForURL(input)).toEqual(expected);
+ });
+ });
});
diff --git a/spec/frontend/monitoring/validators_spec.js b/spec/frontend/monitoring/validators_spec.js
new file mode 100644
index 00000000000..0c3d77a7d98
--- /dev/null
+++ b/spec/frontend/monitoring/validators_spec.js
@@ -0,0 +1,80 @@
+import { alertsValidator, queriesValidator } from '~/monitoring/validators';
+
+describe('alertsValidator', () => {
+ const validAlert = {
+ alert_path: 'my/alert.json',
+ operator: '<',
+ threshold: 5,
+ metricId: '8',
+ };
+ it('requires all alerts to have an alert path', () => {
+ const { operator, threshold, metricId } = validAlert;
+ const input = {
+ [validAlert.alert_path]: {
+ operator,
+ threshold,
+ metricId,
+ },
+ };
+ expect(alertsValidator(input)).toEqual(false);
+ });
+ it('requires that the object key matches the alert path', () => {
+ const input = {
+ undefined: validAlert,
+ };
+ expect(alertsValidator(input)).toEqual(false);
+ });
+ it('requires all alerts to have a metric id', () => {
+ const input = {
+ [validAlert.alert_path]: { ...validAlert, metricId: undefined },
+ };
+ expect(alertsValidator(input)).toEqual(false);
+ });
+ it('requires the metricId to be a string', () => {
+ const input = {
+ [validAlert.alert_path]: { ...validAlert, metricId: 8 },
+ };
+ expect(alertsValidator(input)).toEqual(false);
+ });
+ it('requires all alerts to have an operator', () => {
+ const input = {
+ [validAlert.alert_path]: { ...validAlert, operator: '' },
+ };
+ expect(alertsValidator(input)).toEqual(false);
+ });
+ it('requires all alerts to have an numeric threshold', () => {
+ const input = {
+ [validAlert.alert_path]: { ...validAlert, threshold: '60' },
+ };
+ expect(alertsValidator(input)).toEqual(false);
+ });
+ it('correctly identifies a valid alerts object', () => {
+ const input = {
+ [validAlert.alert_path]: validAlert,
+ };
+ expect(alertsValidator(input)).toEqual(true);
+ });
+});
+describe('queriesValidator', () => {
+ const validQuery = {
+ metricId: '8',
+ alert_path: 'alert',
+ label: 'alert-label',
+ };
+ it('requires all alerts to have a metric id', () => {
+ const input = [{ ...validQuery, metricId: undefined }];
+ expect(queriesValidator(input)).toEqual(false);
+ });
+ it('requires the metricId to be a string', () => {
+ const input = [{ ...validQuery, metricId: 8 }];
+ expect(queriesValidator(input)).toEqual(false);
+ });
+ it('requires all queries to have a label', () => {
+ const input = [{ ...validQuery, label: undefined }];
+ expect(queriesValidator(input)).toEqual(false);
+ });
+ it('correctly identifies a valid queries array', () => {
+ const input = [validQuery];
+ expect(queriesValidator(input)).toEqual(true);
+ });
+});
diff --git a/spec/frontend/notebook/cells/code_spec.js b/spec/frontend/notebook/cells/code_spec.js
new file mode 100644
index 00000000000..33dabe2b6dc
--- /dev/null
+++ b/spec/frontend/notebook/cells/code_spec.js
@@ -0,0 +1,90 @@
+import Vue from 'vue';
+import CodeComponent from '~/notebook/cells/code.vue';
+
+const Component = Vue.extend(CodeComponent);
+
+describe('Code component', () => {
+ let vm;
+ let json;
+
+ beforeEach(() => {
+ json = getJSONFixture('blob/notebook/basic.json');
+ });
+
+ const setupComponent = cell => {
+ const comp = new Component({
+ propsData: {
+ cell,
+ },
+ });
+ comp.$mount();
+ return comp;
+ };
+
+ describe('without output', () => {
+ beforeEach(done => {
+ vm = setupComponent(json.cells[0]);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('does not render output prompt', () => {
+ expect(vm.$el.querySelectorAll('.prompt').length).toBe(1);
+ });
+ });
+
+ describe('with output', () => {
+ beforeEach(done => {
+ vm = setupComponent(json.cells[2]);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('does not render output prompt', () => {
+ expect(vm.$el.querySelectorAll('.prompt').length).toBe(2);
+ });
+
+ it('renders output cell', () => {
+ expect(vm.$el.querySelector('.output')).toBeDefined();
+ });
+ });
+
+ describe('with string for output', () => {
+ // NBFormat Version 4.1 allows outputs.text to be a string
+ beforeEach(() => {
+ const cell = json.cells[2];
+ cell.outputs[0].text = cell.outputs[0].text.join('');
+
+ vm = setupComponent(cell);
+ return vm.$nextTick();
+ });
+
+ it('does not render output prompt', () => {
+ expect(vm.$el.querySelectorAll('.prompt').length).toBe(2);
+ });
+
+ it('renders output cell', () => {
+ expect(vm.$el.querySelector('.output')).toBeDefined();
+ });
+ });
+
+ describe('with string for cell.source', () => {
+ beforeEach(() => {
+ const cell = json.cells[0];
+ cell.source = cell.source.join('');
+
+ vm = setupComponent(cell);
+ return vm.$nextTick();
+ });
+
+ it('renders the same input as when cell.source is an array', () => {
+ const expected = "console.log('test')";
+
+ expect(vm.$el.querySelector('.input').innerText).toContain(expected);
+ });
+ });
+});
diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js
new file mode 100644
index 00000000000..ad33858da22
--- /dev/null
+++ b/spec/frontend/notebook/cells/markdown_spec.js
@@ -0,0 +1,167 @@
+import Vue from 'vue';
+import katex from 'katex';
+import MarkdownComponent from '~/notebook/cells/markdown.vue';
+
+const Component = Vue.extend(MarkdownComponent);
+
+window.katex = katex;
+
+describe('Markdown component', () => {
+ let vm;
+ let cell;
+ let json;
+
+ beforeEach(() => {
+ json = getJSONFixture('blob/notebook/basic.json');
+
+ // eslint-disable-next-line prefer-destructuring
+ cell = json.cells[1];
+
+ vm = new Component({
+ propsData: {
+ cell,
+ },
+ });
+ vm.$mount();
+
+ return vm.$nextTick();
+ });
+
+ it('does not render promot', () => {
+ expect(vm.$el.querySelector('.prompt span')).toBeNull();
+ });
+
+ it('does not render the markdown text', () => {
+ expect(vm.$el.querySelector('.markdown').innerHTML.trim()).not.toEqual(cell.source.join(''));
+ });
+
+ it('renders the markdown HTML', () => {
+ expect(vm.$el.querySelector('.markdown h1')).not.toBeNull();
+ });
+
+ it('sanitizes output', () => {
+ Object.assign(cell, {
+ source: [
+ '[XSS](data:text/html;base64,PHNjcmlwdD5hbGVydChkb2N1bWVudC5kb21haW4pPC9zY3JpcHQ+Cg==)\n',
+ ],
+ });
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('a').getAttribute('href')).toBeNull();
+ });
+ });
+
+ describe('katex', () => {
+ beforeEach(() => {
+ json = getJSONFixture('blob/notebook/math.json');
+ });
+
+ it('renders multi-line katex', () => {
+ vm = new Component({
+ propsData: {
+ cell: json.cells[0],
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.katex')).not.toBeNull();
+ });
+ });
+
+ it('renders inline katex', () => {
+ vm = new Component({
+ propsData: {
+ cell: json.cells[1],
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('p:first-child .katex')).not.toBeNull();
+ });
+ });
+
+ it('renders multiple inline katex', () => {
+ vm = new Component({
+ propsData: {
+ cell: json.cells[1],
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelectorAll('p:nth-child(2) .katex').length).toBe(4);
+ });
+ });
+
+ it('output cell in case of katex error', () => {
+ vm = new Component({
+ propsData: {
+ cell: {
+ cell_type: 'markdown',
+ metadata: {},
+ source: ['Some invalid $a & b$ inline formula $b & c$\n', '\n'],
+ },
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ // expect one paragraph with no katex formula in it
+ expect(vm.$el.querySelectorAll('p').length).toBe(1);
+ expect(vm.$el.querySelectorAll('p .katex').length).toBe(0);
+ });
+ });
+
+ it('output cell and render remaining formula in case of katex error', () => {
+ vm = new Component({
+ propsData: {
+ cell: {
+ cell_type: 'markdown',
+ metadata: {},
+ source: ['An invalid $a & b$ inline formula and a vaild one $b = c$\n', '\n'],
+ },
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ // expect one paragraph with no katex formula in it
+ expect(vm.$el.querySelectorAll('p').length).toBe(1);
+ expect(vm.$el.querySelectorAll('p .katex').length).toBe(1);
+ });
+ });
+
+ it('renders math formula in list object', () => {
+ vm = new Component({
+ propsData: {
+ cell: {
+ cell_type: 'markdown',
+ metadata: {},
+ source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'],
+ },
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ // expect one list with a katex formula in it
+ expect(vm.$el.querySelectorAll('li').length).toBe(1);
+ expect(vm.$el.querySelectorAll('li .katex').length).toBe(2);
+ });
+ });
+
+ it("renders math formula with tick ' in it", () => {
+ vm = new Component({
+ propsData: {
+ cell: {
+ cell_type: 'markdown',
+ metadata: {},
+ source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'],
+ },
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ // expect one list with a katex formula in it
+ expect(vm.$el.querySelectorAll('li').length).toBe(1);
+ expect(vm.$el.querySelectorAll('li .katex').length).toBe(2);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/notebook/cells/output/html_sanitize_tests.js b/spec/frontend/notebook/cells/output/html_sanitize_tests.js
index 74c48f04367..74c48f04367 100644
--- a/spec/javascripts/notebook/cells/output/html_sanitize_tests.js
+++ b/spec/frontend/notebook/cells/output/html_sanitize_tests.js
diff --git a/spec/javascripts/notebook/cells/output/html_spec.js b/spec/frontend/notebook/cells/output/html_spec.js
index 3ee404fb187..3ee404fb187 100644
--- a/spec/javascripts/notebook/cells/output/html_spec.js
+++ b/spec/frontend/notebook/cells/output/html_spec.js
diff --git a/spec/frontend/notebook/cells/output/index_spec.js b/spec/frontend/notebook/cells/output/index_spec.js
new file mode 100644
index 00000000000..2b1aa5317c5
--- /dev/null
+++ b/spec/frontend/notebook/cells/output/index_spec.js
@@ -0,0 +1,115 @@
+import Vue from 'vue';
+import CodeComponent from '~/notebook/cells/output/index.vue';
+
+const Component = Vue.extend(CodeComponent);
+
+describe('Output component', () => {
+ let vm;
+ let json;
+
+ const createComponent = output => {
+ vm = new Component({
+ propsData: {
+ outputs: [].concat(output),
+ count: 1,
+ },
+ });
+ vm.$mount();
+ };
+
+ beforeEach(() => {
+ json = getJSONFixture('blob/notebook/basic.json');
+ });
+
+ describe('text output', () => {
+ beforeEach(done => {
+ createComponent(json.cells[2].outputs[0]);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders as plain text', () => {
+ expect(vm.$el.querySelector('pre')).not.toBeNull();
+ });
+
+ it('renders promot', () => {
+ expect(vm.$el.querySelector('.prompt span')).not.toBeNull();
+ });
+ });
+
+ describe('image output', () => {
+ beforeEach(done => {
+ createComponent(json.cells[3].outputs[0]);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders as an image', () => {
+ expect(vm.$el.querySelector('img')).not.toBeNull();
+ });
+ });
+
+ describe('html output', () => {
+ it('renders raw HTML', () => {
+ createComponent(json.cells[4].outputs[0]);
+
+ expect(vm.$el.querySelector('p')).not.toBeNull();
+ expect(vm.$el.querySelectorAll('p').length).toBe(1);
+ expect(vm.$el.textContent.trim()).toContain('test');
+ });
+
+ it('renders multiple raw HTML outputs', () => {
+ createComponent([json.cells[4].outputs[0], json.cells[4].outputs[0]]);
+
+ expect(vm.$el.querySelectorAll('p').length).toBe(2);
+ });
+ });
+
+ describe('svg output', () => {
+ beforeEach(done => {
+ createComponent(json.cells[5].outputs[0]);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders as an svg', () => {
+ expect(vm.$el.querySelector('svg')).not.toBeNull();
+ });
+ });
+
+ describe('default to plain text', () => {
+ beforeEach(done => {
+ createComponent(json.cells[6].outputs[0]);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders as plain text', () => {
+ expect(vm.$el.querySelector('pre')).not.toBeNull();
+ expect(vm.$el.textContent.trim()).toContain('testing');
+ });
+
+ it('renders promot', () => {
+ expect(vm.$el.querySelector('.prompt span')).not.toBeNull();
+ });
+
+ it("renders as plain text when doesn't recognise other types", done => {
+ createComponent(json.cells[7].outputs[0]);
+
+ setImmediate(() => {
+ expect(vm.$el.querySelector('pre')).not.toBeNull();
+ expect(vm.$el.textContent.trim()).toContain('testing');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/notebook/cells/prompt_spec.js b/spec/frontend/notebook/cells/prompt_spec.js
new file mode 100644
index 00000000000..cf5a7a603c6
--- /dev/null
+++ b/spec/frontend/notebook/cells/prompt_spec.js
@@ -0,0 +1,56 @@
+import Vue from 'vue';
+import PromptComponent from '~/notebook/cells/prompt.vue';
+
+const Component = Vue.extend(PromptComponent);
+
+describe('Prompt component', () => {
+ let vm;
+
+ describe('input', () => {
+ beforeEach(done => {
+ vm = new Component({
+ propsData: {
+ type: 'In',
+ count: 1,
+ },
+ });
+ vm.$mount();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders in label', () => {
+ expect(vm.$el.textContent.trim()).toContain('In');
+ });
+
+ it('renders count', () => {
+ expect(vm.$el.textContent.trim()).toContain('1');
+ });
+ });
+
+ describe('output', () => {
+ beforeEach(done => {
+ vm = new Component({
+ propsData: {
+ type: 'Out',
+ count: 1,
+ },
+ });
+ vm.$mount();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders in label', () => {
+ expect(vm.$el.textContent.trim()).toContain('Out');
+ });
+
+ it('renders count', () => {
+ expect(vm.$el.textContent.trim()).toContain('1');
+ });
+ });
+});
diff --git a/spec/frontend/notebook/index_spec.js b/spec/frontend/notebook/index_spec.js
new file mode 100644
index 00000000000..36b092be976
--- /dev/null
+++ b/spec/frontend/notebook/index_spec.js
@@ -0,0 +1,100 @@
+import Vue from 'vue';
+import Notebook from '~/notebook/index.vue';
+
+const Component = Vue.extend(Notebook);
+
+describe('Notebook component', () => {
+ let vm;
+ let json;
+ let jsonWithWorksheet;
+
+ beforeEach(() => {
+ json = getJSONFixture('blob/notebook/basic.json');
+ jsonWithWorksheet = getJSONFixture('blob/notebook/worksheets.json');
+ });
+
+ describe('without JSON', () => {
+ beforeEach(done => {
+ vm = new Component({
+ propsData: {
+ notebook: {},
+ },
+ });
+ vm.$mount();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('does not render', () => {
+ expect(vm.$el.tagName).toBeUndefined();
+ });
+ });
+
+ describe('with JSON', () => {
+ beforeEach(done => {
+ vm = new Component({
+ propsData: {
+ notebook: json,
+ codeCssClass: 'js-code-class',
+ },
+ });
+ vm.$mount();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders cells', () => {
+ expect(vm.$el.querySelectorAll('.cell').length).toBe(json.cells.length);
+ });
+
+ it('renders markdown cell', () => {
+ expect(vm.$el.querySelector('.markdown')).not.toBeNull();
+ });
+
+ it('renders code cell', () => {
+ expect(vm.$el.querySelector('pre')).not.toBeNull();
+ });
+
+ it('add code class to code blocks', () => {
+ expect(vm.$el.querySelector('.js-code-class')).not.toBeNull();
+ });
+ });
+
+ describe('with worksheets', () => {
+ beforeEach(done => {
+ vm = new Component({
+ propsData: {
+ notebook: jsonWithWorksheet,
+ codeCssClass: 'js-code-class',
+ },
+ });
+ vm.$mount();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders cells', () => {
+ expect(vm.$el.querySelectorAll('.cell').length).toBe(
+ jsonWithWorksheet.worksheets[0].cells.length,
+ );
+ });
+
+ it('renders markdown cell', () => {
+ expect(vm.$el.querySelector('.markdown')).not.toBeNull();
+ });
+
+ it('renders code cell', () => {
+ expect(vm.$el.querySelector('pre')).not.toBeNull();
+ });
+
+ it('add code class to code blocks', () => {
+ expect(vm.$el.querySelector('.js-code-class')).not.toBeNull();
+ });
+ });
+});
diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js
index a2c7f0b3767..dc68c4371aa 100644
--- a/spec/frontend/notes/components/comment_form_spec.js
+++ b/spec/frontend/notes/components/comment_form_spec.js
@@ -9,12 +9,7 @@ import CommentForm from '~/notes/components/comment_form.vue';
import * as constants from '~/notes/constants';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { keyboardDownEvent } from '../../issue_show/helpers';
-import {
- loggedOutnoteableData,
- notesDataMock,
- userDataMock,
- noteableDataMock,
-} from '../../notes/mock_data';
+import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data';
jest.mock('autosize');
jest.mock('~/commons/nav/user_merge_requests');
diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js
index 5101b81e3ee..44dc148933c 100644
--- a/spec/frontend/notes/components/discussion_actions_spec.js
+++ b/spec/frontend/notes/components/discussion_actions_spec.js
@@ -1,5 +1,5 @@
import { shallowMount, mount } from '@vue/test-utils';
-import { discussionMock } from '../../notes/mock_data';
+import { discussionMock } from '../mock_data';
import DiscussionActions from '~/notes/components/discussion_actions.vue';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import ResolveDiscussionButton from '~/notes/components/discussion_resolve_button.vue';
diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js
index 77603c16f82..04535aa17c5 100644
--- a/spec/frontend/notes/components/discussion_counter_spec.js
+++ b/spec/frontend/notes/components/discussion_counter_spec.js
@@ -75,15 +75,14 @@ describe('DiscussionCounter component', () => {
});
it.each`
- title | resolved | isActive | icon | groupLength
- ${'not allResolved'} | ${false} | ${false} | ${'check-circle'} | ${3}
- ${'allResolved'} | ${true} | ${true} | ${'check-circle-filled'} | ${1}
- `('renders correctly if $title', ({ resolved, isActive, icon, groupLength }) => {
+ title | resolved | isActive | groupLength
+ ${'not allResolved'} | ${false} | ${false} | ${3}
+ ${'allResolved'} | ${true} | ${true} | ${1}
+ `('renders correctly if $title', ({ resolved, isActive, groupLength }) => {
updateStore({ resolvable: true, resolved });
wrapper = shallowMount(DiscussionCounter, { store, localVue });
expect(wrapper.find(`.is-active`).exists()).toBe(isActive);
- expect(wrapper.find({ name: icon }).exists()).toBe(true);
expect(wrapper.findAll('[role="group"').length).toBe(groupLength);
});
});
diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js
index b8d2d721443..7f042c0e9de 100644
--- a/spec/frontend/notes/components/discussion_filter_spec.js
+++ b/spec/frontend/notes/components/discussion_filter_spec.js
@@ -1,4 +1,4 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
import Vuex from 'vuex';
import { createLocalVue, mount } from '@vue/test-utils';
@@ -132,7 +132,7 @@ describe('DiscussionFilter component', () => {
});
describe('Merge request tabs', () => {
- eventHub = new Vue();
+ eventHub = createEventHub();
beforeEach(() => {
window.mrTabs = {
diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js
index 81773752037..5a10deefd09 100644
--- a/spec/frontend/notes/components/discussion_notes_spec.js
+++ b/spec/frontend/notes/components/discussion_notes_spec.js
@@ -7,7 +7,7 @@ import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'
import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
import SystemNote from '~/vue_shared/components/notes/system_note.vue';
import createStore from '~/notes/stores';
-import { noteableDataMock, discussionMock, notesDataMock } from '../../notes/mock_data';
+import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data';
describe('DiscussionNotes', () => {
let wrapper;
diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js
index bccac03126c..8270c148fb5 100644
--- a/spec/frontend/notes/components/note_form_spec.js
+++ b/spec/frontend/notes/components/note_form_spec.js
@@ -161,18 +161,18 @@ describe('issue_note_form component', () => {
describe('actions', () => {
it('should be possible to cancel', () => {
- // TODO: do not spy on vm
- jest.spyOn(wrapper.vm, 'cancelHandler');
+ const cancelHandler = jest.fn();
wrapper.setProps({
...props,
isEditing: true,
});
+ wrapper.setMethods({ cancelHandler });
return wrapper.vm.$nextTick().then(() => {
- const cancelButton = wrapper.find('.note-edit-cancel');
+ const cancelButton = wrapper.find('[data-testid="cancel"]');
cancelButton.trigger('click');
- expect(wrapper.vm.cancelHandler).toHaveBeenCalled();
+ expect(cancelHandler).toHaveBeenCalledWith(true);
});
});
diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js
index d477de69716..2bb08b60569 100644
--- a/spec/frontend/notes/components/note_header_spec.js
+++ b/spec/frontend/notes/components/note_header_spec.js
@@ -1,7 +1,7 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { nextTick } from 'vue';
import Vuex from 'vuex';
import NoteHeader from '~/notes/components/note_header.vue';
-import GitlabTeamMemberBadge from '~/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -18,6 +18,7 @@ describe('NoteHeader component', () => {
const findActionText = () => wrapper.find({ ref: 'actionText' });
const findTimestampLink = () => wrapper.find({ ref: 'noteTimestampLink' });
const findTimestamp = () => wrapper.find({ ref: 'noteTimestamp' });
+ const findConfidentialIndicator = () => wrapper.find('[data-testid="confidentialIndicator"]');
const findSpinner = () => wrapper.find({ ref: 'spinner' });
const author = {
@@ -140,20 +141,6 @@ describe('NoteHeader component', () => {
});
});
- test.each`
- props | expected | message1 | message2
- ${{ author: { ...author, is_gitlab_employee: true } }} | ${true} | ${'renders'} | ${'true'}
- ${{ author: { ...author, is_gitlab_employee: false } }} | ${false} | ${"doesn't render"} | ${'false'}
- ${{ author }} | ${false} | ${"doesn't render"} | ${'undefined'}
- `(
- '$message1 GitLab team member badge when `is_gitlab_employee` is $message2',
- ({ props, expected }) => {
- createComponent(props);
-
- expect(wrapper.find(GitlabTeamMemberBadge).exists()).toBe(expected);
- },
- );
-
describe('loading spinner', () => {
it('shows spinner when showSpinner is true', () => {
createComponent();
@@ -179,4 +166,81 @@ describe('NoteHeader component', () => {
expect(findTimestamp().exists()).toBe(true);
});
});
+
+ describe('author username link', () => {
+ it('proxies `mouseenter` event to author name link', () => {
+ createComponent({ author });
+
+ const dispatchEvent = jest.spyOn(wrapper.vm.$refs.authorNameLink, 'dispatchEvent');
+
+ wrapper.find({ ref: 'authorUsernameLink' }).trigger('mouseenter');
+
+ expect(dispatchEvent).toHaveBeenCalledWith(new Event('mouseenter'));
+ });
+
+ it('proxies `mouseleave` event to author name link', () => {
+ createComponent({ author });
+
+ const dispatchEvent = jest.spyOn(wrapper.vm.$refs.authorNameLink, 'dispatchEvent');
+
+ wrapper.find({ ref: 'authorUsernameLink' }).trigger('mouseleave');
+
+ expect(dispatchEvent).toHaveBeenCalledWith(new Event('mouseleave'));
+ });
+ });
+
+ describe('when author status tooltip is opened', () => {
+ it('removes `title` attribute from emoji to prevent duplicate tooltips', () => {
+ createComponent({
+ author: {
+ ...author,
+ status_tooltip_html:
+ '"<span class="user-status-emoji has-tooltip" title="foo bar" data-html="true" data-placement="top"><gl-emoji title="basketball and hoop" data-name="basketball" data-unicode-version="6.0">🏀</gl-emoji></span>"',
+ },
+ });
+
+ return nextTick().then(() => {
+ const authorStatus = wrapper.find({ ref: 'authorStatus' });
+ authorStatus.trigger('mouseenter');
+
+ expect(authorStatus.find('gl-emoji').attributes('title')).toBeUndefined();
+ });
+ });
+ });
+
+ describe('when author username link is hovered', () => {
+ it('toggles hover specific CSS classes on author name link', done => {
+ createComponent({ author });
+
+ const authorUsernameLink = wrapper.find({ ref: 'authorUsernameLink' });
+ const authorNameLink = wrapper.find({ ref: 'authorNameLink' });
+
+ authorUsernameLink.trigger('mouseenter');
+
+ nextTick(() => {
+ expect(authorNameLink.classes()).toContain('hover');
+ expect(authorNameLink.classes()).toContain('text-underline');
+
+ authorUsernameLink.trigger('mouseleave');
+
+ nextTick(() => {
+ expect(authorNameLink.classes()).not.toContain('hover');
+ expect(authorNameLink.classes()).not.toContain('text-underline');
+
+ done();
+ });
+ });
+ });
+ });
+
+ describe('with confidentiality indicator', () => {
+ it.each`
+ status | condition
+ ${true} | ${'shows'}
+ ${false} | ${'hides'}
+ `('$condition icon indicator when isConfidential is $status', ({ status }) => {
+ createComponent({ isConfidential: status });
+ expect(findConfidentialIndicator().exists()).toBe(status);
+ });
+ });
});
diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js
index b91f599f158..b14ec2a65be 100644
--- a/spec/frontend/notes/components/noteable_discussion_spec.js
+++ b/spec/frontend/notes/components/noteable_discussion_spec.js
@@ -138,7 +138,7 @@ describe('noteable_discussion component', () => {
describe('signout widget', () => {
beforeEach(() => {
- originalGon = Object.assign({}, window.gon);
+ originalGon = { ...window.gon };
window.gon = window.gon || {};
});
diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js
index e22dd85f221..fbfba2efb1d 100644
--- a/spec/frontend/notes/components/notes_app_spec.js
+++ b/spec/frontend/notes/components/notes_app_spec.js
@@ -10,7 +10,7 @@ import createStore from '~/notes/stores';
import * as constants from '~/notes/constants';
import '~/behaviors/markdown/render_gfm';
// TODO: use generated fixture (https://gitlab.com/gitlab-org/gitlab-foss/issues/62491)
-import * as mockData from '../../notes/mock_data';
+import * as mockData from '../mock_data';
import * as urlUtility from '~/lib/utils/url_utility';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js
index 4e5325b8bc3..120de023099 100644
--- a/spec/frontend/notes/mixins/discussion_navigation_spec.js
+++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js
@@ -3,6 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import * as utils from '~/lib/utils/common_utils';
import discussionNavigation from '~/notes/mixins/discussion_navigation';
import eventHub from '~/notes/event_hub';
+import createEventHub from '~/helpers/event_hub_factory';
import notesModule from '~/notes/stores/modules';
import { setHTMLFixture } from 'helpers/fixtures';
@@ -67,8 +68,7 @@ describe('Discussion navigation mixin', () => {
describe('cycle through discussions', () => {
beforeEach(() => {
- // eslint-disable-next-line new-cap
- window.mrTabs = { eventHub: new localVue(), tabShown: jest.fn() };
+ window.mrTabs = { eventHub: createEventHub(), tabShown: jest.fn() };
});
describe.each`
diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js
index 9ed79c61c22..980faac2b04 100644
--- a/spec/frontend/notes/mock_data.js
+++ b/spec/frontend/notes/mock_data.js
@@ -57,6 +57,7 @@ export const noteableDataMock = {
updated_by_id: 1,
web_url: '/gitlab-org/gitlab-foss/issues/26',
noteableType: 'issue',
+ blocked_by_issues: [],
};
export const lastFetchedAt = '1501862675';
diff --git a/spec/frontend/notes/old_notes_spec.js b/spec/frontend/notes/old_notes_spec.js
index 49b887b21b4..cb1d563ece7 100644
--- a/spec/frontend/notes/old_notes_spec.js
+++ b/spec/frontend/notes/old_notes_spec.js
@@ -33,7 +33,6 @@ gl.utils.disableButtonIfEmptyField = () => {};
// eslint-disable-next-line jest/no-disabled-tests
describe.skip('Old Notes (~/notes.js)', () => {
beforeEach(() => {
- jest.useFakeTimers();
loadFixtures(fixture);
// Re-declare this here so that test_setup.js#beforeEach() doesn't
@@ -194,7 +193,7 @@ describe.skip('Old Notes (~/notes.js)', () => {
$('.js-comment-button').click();
const $targetNote = $notesContainer.find(`#note_${noteEntity.id}`);
- const updatedNote = Object.assign({}, noteEntity);
+ const updatedNote = { ...noteEntity };
updatedNote.note = 'bar';
notes.updateNote(updatedNote, $targetNote);
@@ -213,13 +212,6 @@ describe.skip('Old Notes (~/notes.js)', () => {
jest.spyOn($note, 'toggleClass');
});
- afterEach(() => {
- expect(typeof urlUtility.getLocationHash.mock).toBe('object');
- urlUtility.getLocationHash.mockRestore();
- expect(urlUtility.getLocationHash.mock).toBeUndefined();
- expect(urlUtility.getLocationHash()).toBeNull();
- });
-
// urlUtility is a dependency of the notes module. Its getLocatinHash() method should be called internally.
it('sets target when hash matches', () => {
@@ -630,48 +622,6 @@ describe.skip('Old Notes (~/notes.js)', () => {
done();
});
});
-
- // This is a bad test carried over from the Karma -> Jest migration.
- // The corresponding test in the Karma suite tests for
- // elements and methods that don't actually exist, and gives a false
- // positive pass.
- /*
- it('should show flash error message when comment failed to be updated', done => {
- mockNotesPost();
- jest.spyOn(notes, 'addFlash').mockName('addFlash');
-
- $('.js-comment-button').click();
-
- deferredPromise()
- .then(() => {
- const $noteEl = $notesContainer.find(`#note_${note.id}`);
- $noteEl.find('.js-note-edit').click();
- $noteEl.find('textarea.js-note-text').val(updatedComment);
-
- mockNotesPostError();
-
- $noteEl.find('.js-comment-save-button').click();
- notes.updateComment({preventDefault: () => {}});
- })
- .then(() => deferredPromise())
- .then(() => {
- const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`);
-
- expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals
- expect(
- $updatedNoteEl
- .find('.note-text')
- .text()
- .trim(),
- ).toEqual(sampleComment); // See if comment reverted back to original
-
- expect(notes.addFlash).toHaveBeenCalled();
- expect(notes.flashContainer.style.display).not.toBe('none');
- done();
- })
- .catch(done.fail);
- }, 5000);
- */
});
describe('postComment with Slash commands', () => {
diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js
index 544d482e7fc..cbfb9597159 100644
--- a/spec/frontend/notes/stores/actions_spec.js
+++ b/spec/frontend/notes/stores/actions_spec.js
@@ -34,6 +34,11 @@ describe('Actions Notes Store', () => {
dispatch = jest.fn();
state = {};
axiosMock = new AxiosMockAdapter(axios);
+
+ // This is necessary as we query Close issue button at the top of issue page when clicking bottom button
+ setFixtures(
+ '<div class="detail-page-header-actions"><button class="btn-close btn-grouped"></button></div>',
+ );
});
afterEach(() => {
@@ -242,9 +247,31 @@ describe('Actions Notes Store', () => {
});
});
- describe('poll', () => {
- jest.useFakeTimers();
+ describe('toggleBlockedIssueWarning', () => {
+ it('should set issue warning as true', done => {
+ testAction(
+ actions.toggleBlockedIssueWarning,
+ true,
+ {},
+ [{ type: 'TOGGLE_BLOCKED_ISSUE_WARNING', payload: true }],
+ [],
+ done,
+ );
+ });
+ it('should set issue warning as false', done => {
+ testAction(
+ actions.toggleBlockedIssueWarning,
+ false,
+ {},
+ [{ type: 'TOGGLE_BLOCKED_ISSUE_WARNING', payload: false }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('poll', () => {
beforeEach(done => {
jest.spyOn(axios, 'get');
diff --git a/spec/frontend/notes/stores/collapse_utils_spec.js b/spec/frontend/notes/stores/collapse_utils_spec.js
index d3019f4b9a4..a74809eed79 100644
--- a/spec/frontend/notes/stores/collapse_utils_spec.js
+++ b/spec/frontend/notes/stores/collapse_utils_spec.js
@@ -18,9 +18,7 @@ describe('Collapse utils', () => {
});
it('returns false when a system note is not a description type', () => {
- expect(isDescriptionSystemNote(Object.assign({}, mockSystemNote, { note: 'foo' }))).toEqual(
- false,
- );
+ expect(isDescriptionSystemNote({ ...mockSystemNote, note: 'foo' })).toEqual(false);
});
it('gets the time difference between two notes', () => {
diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js
index 06d2654ceca..27e3490d64b 100644
--- a/spec/frontend/notes/stores/mutation_spec.js
+++ b/spec/frontend/notes/stores/mutation_spec.js
@@ -50,7 +50,7 @@ describe('Notes Store mutations', () => {
});
describe('ADD_NEW_REPLY_TO_DISCUSSION', () => {
- const newReply = Object.assign({}, note, { discussion_id: discussionMock.id });
+ const newReply = { ...note, discussion_id: discussionMock.id };
let state;
@@ -86,7 +86,7 @@ describe('Notes Store mutations', () => {
describe('EXPAND_DISCUSSION', () => {
it('should expand a collapsed discussion', () => {
- const discussion = Object.assign({}, discussionMock, { expanded: false });
+ const discussion = { ...discussionMock, expanded: false };
const state = {
discussions: [discussion],
@@ -100,7 +100,7 @@ describe('Notes Store mutations', () => {
describe('COLLAPSE_DISCUSSION', () => {
it('should collapse an expanded discussion', () => {
- const discussion = Object.assign({}, discussionMock, { expanded: true });
+ const discussion = { ...discussionMock, expanded: true };
const state = {
discussions: [discussion],
@@ -114,7 +114,7 @@ describe('Notes Store mutations', () => {
describe('REMOVE_PLACEHOLDER_NOTES', () => {
it('should remove all placeholder notes in indivudal notes and discussion', () => {
- const placeholderNote = Object.assign({}, individualNote, { isPlaceholderNote: true });
+ const placeholderNote = { ...individualNote, isPlaceholderNote: true };
const state = { discussions: [placeholderNote] };
mutations.REMOVE_PLACEHOLDER_NOTES(state);
@@ -298,7 +298,7 @@ describe('Notes Store mutations', () => {
describe('TOGGLE_DISCUSSION', () => {
it('should open a closed discussion', () => {
- const discussion = Object.assign({}, discussionMock, { expanded: false });
+ const discussion = { ...discussionMock, expanded: false };
const state = {
discussions: [discussion],
@@ -348,8 +348,8 @@ describe('Notes Store mutations', () => {
});
it('should open all closed discussions', () => {
- const discussion1 = Object.assign({}, discussionMock, { id: 0, expanded: false });
- const discussion2 = Object.assign({}, discussionMock, { id: 1, expanded: true });
+ const discussion1 = { ...discussionMock, id: 0, expanded: false };
+ const discussion2 = { ...discussionMock, id: 1, expanded: true };
const discussionIds = [discussion1.id, discussion2.id];
const state = { discussions: [discussion1, discussion2] };
@@ -362,8 +362,8 @@ describe('Notes Store mutations', () => {
});
it('should close all opened discussions', () => {
- const discussion1 = Object.assign({}, discussionMock, { id: 0, expanded: false });
- const discussion2 = Object.assign({}, discussionMock, { id: 1, expanded: true });
+ const discussion1 = { ...discussionMock, id: 0, expanded: false };
+ const discussion2 = { ...discussionMock, id: 1, expanded: true };
const discussionIds = [discussion1.id, discussion2.id];
const state = { discussions: [discussion1, discussion2] };
@@ -382,7 +382,7 @@ describe('Notes Store mutations', () => {
discussions: [individualNote],
};
- const updated = Object.assign({}, individualNote.notes[0], { note: 'Foo' });
+ const updated = { ...individualNote.notes[0], note: 'Foo' };
mutations.UPDATE_NOTE(state, updated);
@@ -664,4 +664,40 @@ describe('Notes Store mutations', () => {
expect(state.discussionSortOrder).toBe(DESC);
});
});
+
+ describe('TOGGLE_BLOCKED_ISSUE_WARNING', () => {
+ it('should set isToggleBlockedIssueWarning as true', () => {
+ const state = {
+ discussions: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+ isToggleStateButtonLoading: false,
+ isToggleBlockedIssueWarning: false,
+ notesData: {},
+ userData: {},
+ noteableData: {},
+ };
+
+ mutations.TOGGLE_BLOCKED_ISSUE_WARNING(state, true);
+
+ expect(state.isToggleBlockedIssueWarning).toEqual(true);
+ });
+
+ it('should set isToggleBlockedIssueWarning as false', () => {
+ const state = {
+ discussions: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+ isToggleStateButtonLoading: false,
+ isToggleBlockedIssueWarning: true,
+ notesData: {},
+ userData: {},
+ noteableData: {},
+ };
+
+ mutations.TOGGLE_BLOCKED_ISSUE_WARNING(state, false);
+
+ expect(state.isToggleBlockedIssueWarning).toEqual(false);
+ });
+ });
});
diff --git a/spec/javascripts/oauth_remember_me_spec.js b/spec/frontend/oauth_remember_me_spec.js
index 381be82697e..381be82697e 100644
--- a/spec/javascripts/oauth_remember_me_spec.js
+++ b/spec/frontend/oauth_remember_me_spec.js
diff --git a/spec/javascripts/pages/admin/application_settings/account_and_limits_spec.js b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js
index 6a239e307e9..6a239e307e9 100644
--- a/spec/javascripts/pages/admin/application_settings/account_and_limits_spec.js
+++ b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js
diff --git a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
new file mode 100644
index 00000000000..fe17c03389c
--- /dev/null
+++ b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
@@ -0,0 +1,64 @@
+import Vue from 'vue';
+import { redirectTo } from '~/lib/utils/url_utility';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import axios from '~/lib/utils/axios_utils';
+import stopJobsModal from '~/pages/admin/jobs/index/components/stop_jobs_modal.vue';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ redirectTo: jest.fn(),
+}));
+
+describe('stop_jobs_modal.vue', () => {
+ const props = {
+ url: `${gl.TEST_HOST}/stop_jobs_modal.vue/stopAll`,
+ };
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ beforeEach(() => {
+ const Component = Vue.extend(stopJobsModal);
+ vm = mountComponent(Component, props);
+ });
+
+ describe('onSubmit', () => {
+ it('stops jobs and redirects to overview page', done => {
+ const responseURL = `${gl.TEST_HOST}/stop_jobs_modal.vue/jobs`;
+ jest.spyOn(axios, 'post').mockImplementation(url => {
+ expect(url).toBe(props.url);
+ return Promise.resolve({
+ request: {
+ responseURL,
+ },
+ });
+ });
+
+ vm.onSubmit()
+ .then(() => {
+ expect(redirectTo).toHaveBeenCalledWith(responseURL);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays error if stopping jobs failed', done => {
+ const dummyError = new Error('stopping jobs failed');
+ jest.spyOn(axios, 'post').mockImplementation(url => {
+ expect(url).toBe(props.url);
+ return Promise.reject(dummyError);
+ });
+
+ vm.onSubmit()
+ .then(done.fail)
+ .catch(error => {
+ expect(error).toBe(dummyError);
+ expect(redirectTo).not.toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
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 ea3bedf59e0..82589e5147c 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
@@ -37,7 +37,6 @@ exports[`User Operation confirmation modal renders modal with form included 1`]
value=""
/>
</form>
-
<gl-deprecated-button-stub
size="md"
variant="secondary"
diff --git a/spec/javascripts/pages/admin/users/new/index_spec.js b/spec/frontend/pages/admin/users/new/index_spec.js
index 3896323eef7..3896323eef7 100644
--- a/spec/javascripts/pages/admin/users/new/index_spec.js
+++ b/spec/frontend/pages/admin/users/new/index_spec.js
diff --git a/spec/frontend/pages/labels/components/promote_label_modal_spec.js b/spec/frontend/pages/labels/components/promote_label_modal_spec.js
new file mode 100644
index 00000000000..9d5beca70b5
--- /dev/null
+++ b/spec/frontend/pages/labels/components/promote_label_modal_spec.js
@@ -0,0 +1,103 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue';
+import eventHub from '~/pages/projects/labels/event_hub';
+import axios from '~/lib/utils/axios_utils';
+
+describe('Promote label modal', () => {
+ let vm;
+ const Component = Vue.extend(promoteLabelModal);
+ const labelMockData = {
+ labelTitle: 'Documentation',
+ labelColor: '#5cb85c',
+ labelTextColor: '#ffffff',
+ url: `${gl.TEST_HOST}/dummy/promote/labels`,
+ groupName: 'group',
+ };
+
+ describe('Modal title and description', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, labelMockData);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('contains the proper description', () => {
+ expect(vm.text).toContain(
+ `Promoting ${labelMockData.labelTitle} will make it available for all projects inside ${labelMockData.groupName}`,
+ );
+ });
+
+ it('contains a label span with the color', () => {
+ const labelFromTitle = vm.$el.querySelector('.modal-header .label.color-label');
+
+ expect(labelFromTitle.style.backgroundColor).not.toBe(null);
+ expect(labelFromTitle.textContent).toContain(vm.labelTitle);
+ });
+ });
+
+ describe('When requesting a label promotion', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ ...labelMockData,
+ });
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('redirects when a label is promoted', done => {
+ const responseURL = `${gl.TEST_HOST}/dummy/endpoint`;
+ jest.spyOn(axios, 'post').mockImplementation(url => {
+ expect(url).toBe(labelMockData.url);
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'promoteLabelModal.requestStarted',
+ labelMockData.url,
+ );
+ return Promise.resolve({
+ request: {
+ responseURL,
+ },
+ });
+ });
+
+ vm.onSubmit()
+ .then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
+ labelUrl: labelMockData.url,
+ successful: true,
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays an error if promoting a label failed', done => {
+ const dummyError = new Error('promoting label failed');
+ dummyError.response = { status: 500 };
+ jest.spyOn(axios, 'post').mockImplementation(url => {
+ expect(url).toBe(labelMockData.url);
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'promoteLabelModal.requestStarted',
+ labelMockData.url,
+ );
+ return Promise.reject(dummyError);
+ });
+
+ vm.onSubmit()
+ .catch(error => {
+ expect(error).toBe(dummyError);
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
+ labelUrl: labelMockData.url,
+ successful: false,
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js b/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js
new file mode 100644
index 00000000000..ff5dc6d8988
--- /dev/null
+++ b/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js
@@ -0,0 +1,109 @@
+import Vue from 'vue';
+import { redirectTo } from '~/lib/utils/url_utility';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import axios from '~/lib/utils/axios_utils';
+import deleteMilestoneModal from '~/pages/milestones/shared/components/delete_milestone_modal.vue';
+import eventHub from '~/pages/milestones/shared/event_hub';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ redirectTo: jest.fn(),
+}));
+
+describe('delete_milestone_modal.vue', () => {
+ const Component = Vue.extend(deleteMilestoneModal);
+ const props = {
+ issueCount: 1,
+ mergeRequestCount: 2,
+ milestoneId: 3,
+ milestoneTitle: 'my milestone title',
+ milestoneUrl: `${gl.TEST_HOST}/delete_milestone_modal.vue/milestone`,
+ };
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('onSubmit', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, props);
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ });
+
+ it('deletes milestone and redirects to overview page', done => {
+ const responseURL = `${gl.TEST_HOST}/delete_milestone_modal.vue/milestoneOverview`;
+ jest.spyOn(axios, 'delete').mockImplementation(url => {
+ expect(url).toBe(props.milestoneUrl);
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'deleteMilestoneModal.requestStarted',
+ props.milestoneUrl,
+ );
+ eventHub.$emit.mockReset();
+ return Promise.resolve({
+ request: {
+ responseURL,
+ },
+ });
+ });
+
+ vm.onSubmit()
+ .then(() => {
+ expect(redirectTo).toHaveBeenCalledWith(responseURL);
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
+ milestoneUrl: props.milestoneUrl,
+ successful: true,
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays error if deleting milestone failed', done => {
+ const dummyError = new Error('deleting milestone failed');
+ dummyError.response = { status: 418 };
+ jest.spyOn(axios, 'delete').mockImplementation(url => {
+ expect(url).toBe(props.milestoneUrl);
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'deleteMilestoneModal.requestStarted',
+ props.milestoneUrl,
+ );
+ eventHub.$emit.mockReset();
+ return Promise.reject(dummyError);
+ });
+
+ vm.onSubmit()
+ .catch(error => {
+ expect(error).toBe(dummyError);
+ expect(redirectTo).not.toHaveBeenCalled();
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
+ milestoneUrl: props.milestoneUrl,
+ successful: false,
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('text', () => {
+ it('contains the issue and milestone count', () => {
+ vm = mountComponent(Component, props);
+ const value = vm.text;
+
+ expect(value).toContain('remove it from 1 issue and 2 merge requests');
+ });
+
+ it('contains neither issue nor milestone count', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ issueCount: 0,
+ mergeRequestCount: 0,
+ });
+
+ const value = vm.text;
+
+ expect(value).toContain('is not currently used');
+ });
+ });
+});
diff --git a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js
new file mode 100644
index 00000000000..ff896354d96
--- /dev/null
+++ b/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js
@@ -0,0 +1,98 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import promoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue';
+import eventHub from '~/pages/milestones/shared/event_hub';
+import axios from '~/lib/utils/axios_utils';
+
+describe('Promote milestone modal', () => {
+ let vm;
+ const Component = Vue.extend(promoteMilestoneModal);
+ const milestoneMockData = {
+ milestoneTitle: 'v1.0',
+ url: `${gl.TEST_HOST}/dummy/promote/milestones`,
+ groupName: 'group',
+ };
+
+ describe('Modal title and description', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, milestoneMockData);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('contains the proper description', () => {
+ expect(vm.text).toContain(
+ `Promoting ${milestoneMockData.milestoneTitle} will make it available for all projects inside ${milestoneMockData.groupName}.`,
+ );
+ });
+
+ it('contains the correct title', () => {
+ expect(vm.title).toEqual('Promote v1.0 to group milestone?');
+ });
+ });
+
+ describe('When requesting a milestone promotion', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ ...milestoneMockData,
+ });
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('redirects when a milestone is promoted', done => {
+ const responseURL = `${gl.TEST_HOST}/dummy/endpoint`;
+ jest.spyOn(axios, 'post').mockImplementation(url => {
+ expect(url).toBe(milestoneMockData.url);
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'promoteMilestoneModal.requestStarted',
+ milestoneMockData.url,
+ );
+ return Promise.resolve({
+ request: {
+ responseURL,
+ },
+ });
+ });
+
+ vm.onSubmit()
+ .then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestFinished', {
+ milestoneUrl: milestoneMockData.url,
+ successful: true,
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays an error if promoting a milestone failed', done => {
+ const dummyError = new Error('promoting milestone failed');
+ dummyError.response = { status: 500 };
+ jest.spyOn(axios, 'post').mockImplementation(url => {
+ expect(url).toBe(milestoneMockData.url);
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'promoteMilestoneModal.requestStarted',
+ milestoneMockData.url,
+ );
+ return Promise.reject(dummyError);
+ });
+
+ vm.onSubmit()
+ .catch(error => {
+ expect(error).toBe(dummyError);
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestFinished', {
+ milestoneUrl: milestoneMockData.url,
+ successful: false,
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
new file mode 100644
index 00000000000..9cc1d6eeb5a
--- /dev/null
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
@@ -0,0 +1,154 @@
+import { shallowMount } from '@vue/test-utils';
+import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue';
+
+describe('Interval Pattern Input Component', () => {
+ let oldWindowGl;
+ let wrapper;
+
+ const mockHour = 4;
+ const mockWeekDayIndex = 1;
+ const mockDay = 1;
+
+ const cronIntervalPresets = {
+ everyDay: `0 ${mockHour} * * *`,
+ everyWeek: `0 ${mockHour} * * ${mockWeekDayIndex}`,
+ everyMonth: `0 ${mockHour} ${mockDay} * *`,
+ };
+
+ const findEveryDayRadio = () => wrapper.find('#every-day');
+ const findEveryWeekRadio = () => wrapper.find('#every-week');
+ const findEveryMonthRadio = () => wrapper.find('#every-month');
+ const findCustomRadio = () => wrapper.find('#custom');
+ const findCustomInput = () => wrapper.find('#schedule_cron');
+ const selectEveryDayRadio = () => findEveryDayRadio().setChecked();
+ const selectEveryWeekRadio = () => findEveryWeekRadio().setChecked();
+ const selectEveryMonthRadio = () => findEveryMonthRadio().setChecked();
+ const selectCustomRadio = () => findCustomRadio().trigger('click');
+
+ const createWrapper = (props = {}, data = {}) => {
+ if (wrapper) {
+ throw new Error('A wrapper already exists');
+ }
+
+ wrapper = shallowMount(IntervalPatternInput, {
+ propsData: { ...props },
+ data() {
+ return {
+ randomHour: data?.hour || mockHour,
+ randomWeekDayIndex: mockWeekDayIndex,
+ randomDay: mockDay,
+ };
+ },
+ });
+ };
+
+ beforeEach(() => {
+ oldWindowGl = window.gl;
+ window.gl = {
+ ...(window.gl || {}),
+ pipelineScheduleFieldErrors: {
+ updateFormValidityState: jest.fn(),
+ },
+ };
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ window.gl = oldWindowGl;
+ });
+
+ describe('the input field defaults', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('to a non empty string when no initial value is not passed', () => {
+ expect(findCustomInput()).not.toBe('');
+ });
+ });
+
+ describe('the input field', () => {
+ const initialCron = '0 * * * *';
+
+ beforeEach(() => {
+ createWrapper({ initialCronInterval: initialCron });
+ });
+
+ it('is equal to the prop `initialCronInterval` when passed', () => {
+ expect(findCustomInput().element.value).toBe(initialCron);
+ });
+ });
+
+ describe('The input field is enabled', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('when a default option is selected', () => {
+ selectEveryDayRadio();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findCustomInput().attributes('disabled')).toBeUndefined();
+ });
+ });
+
+ it('when the custom option is selected', () => {
+ selectCustomRadio();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findCustomInput().attributes('disabled')).toBeUndefined();
+ });
+ });
+ });
+
+ describe('formattedTime computed property', () => {
+ it.each`
+ desc | hour | expectedValue
+ ${'returns a time in the afternoon if the value of `random time` is higher than 12'} | ${13} | ${'1:00pm'}
+ ${'returns a time in the morning if the value of `random time` is lower than 12'} | ${11} | ${'11:00am'}
+ ${'returns "12:00pm" if the value of `random time` is exactly 12'} | ${12} | ${'12:00pm'}
+ `('$desc', ({ hour, expectedValue }) => {
+ createWrapper({}, { hour });
+
+ expect(wrapper.vm.formattedTime).toBe(expectedValue);
+ });
+ });
+
+ describe('User Actions with radio buttons', () => {
+ it.each`
+ desc | initialCronInterval | act | expectedValue
+ ${'when everyday is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryDayRadio} | ${cronIntervalPresets.everyDay}
+ ${'when everyweek is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryWeekRadio} | ${cronIntervalPresets.everyWeek}
+ ${'when everymonth is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryMonthRadio} | ${cronIntervalPresets.everyMonth}
+ ${'when custom is selected, add space to value'} | ${cronIntervalPresets.everyMonth} | ${selectCustomRadio} | ${`${cronIntervalPresets.everyMonth} `}
+ `('$desc', ({ initialCronInterval, act, expectedValue }) => {
+ createWrapper({ initialCronInterval });
+
+ act();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findCustomInput().element.value).toBe(expectedValue);
+ });
+ });
+ });
+ describe('User actions with input field for Cron syntax', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('when editing the cron input it selects the custom radio button', () => {
+ const newValue = '0 * * * *';
+
+ findCustomInput().setValue(newValue);
+
+ expect(wrapper.vm.cronInterval).toBe(newValue);
+ });
+
+ it('when value of input is one of the defaults, it selects the corresponding radio button', () => {
+ findCustomInput().setValue(cronIntervalPresets.everyWeek);
+
+ expect(wrapper.vm.cronInterval).toBe(cronIntervalPresets.everyWeek);
+ });
+ });
+});
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
new file mode 100644
index 00000000000..5a61f9fca69
--- /dev/null
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
@@ -0,0 +1,114 @@
+import Vue from 'vue';
+import Cookies from 'js-cookie';
+import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue';
+import '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
+
+jest.mock(
+ '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg',
+ () => '<svg></svg>',
+);
+
+const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout);
+const cookieKey = 'pipeline_schedules_callout_dismissed';
+const docsUrl = 'help/ci/scheduled_pipelines';
+
+describe('Pipeline Schedule Callout', () => {
+ let calloutComponent;
+
+ beforeEach(() => {
+ setFixtures(`
+ <div id='pipeline-schedules-callout' data-docs-url=${docsUrl}></div>
+ `);
+ });
+
+ describe('independent of cookies', () => {
+ beforeEach(() => {
+ calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
+ });
+
+ it('the component can be initialized', () => {
+ expect(calloutComponent).toBeDefined();
+ });
+
+ it('correctly sets illustrationSvg', () => {
+ expect(calloutComponent.illustrationSvg).toContain('<svg');
+ });
+
+ it('correctly sets docsUrl', () => {
+ expect(calloutComponent.docsUrl).toContain(docsUrl);
+ });
+ });
+
+ describe(`when ${cookieKey} cookie is set`, () => {
+ beforeEach(() => {
+ Cookies.set(cookieKey, true);
+ calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
+ });
+
+ it('correctly sets calloutDismissed to true', () => {
+ expect(calloutComponent.calloutDismissed).toBe(true);
+ });
+
+ it('does not render the callout', () => {
+ expect(calloutComponent.$el.childNodes.length).toBe(0);
+ });
+ });
+
+ describe('when cookie is not set', () => {
+ beforeEach(() => {
+ Cookies.remove(cookieKey);
+ calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
+ });
+
+ it('correctly sets calloutDismissed to false', () => {
+ expect(calloutComponent.calloutDismissed).toBe(false);
+ });
+
+ it('renders the callout container', () => {
+ expect(calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull();
+ });
+
+ it('renders the callout svg', () => {
+ expect(calloutComponent.$el.outerHTML).toContain('<svg');
+ });
+
+ it('renders the callout title', () => {
+ expect(calloutComponent.$el.outerHTML).toContain('Scheduling Pipelines');
+ });
+
+ it('renders the callout text', () => {
+ expect(calloutComponent.$el.outerHTML).toContain('runs pipelines in the future');
+ });
+
+ it('renders the documentation url', () => {
+ expect(calloutComponent.$el.outerHTML).toContain(docsUrl);
+ });
+
+ it('updates calloutDismissed when close button is clicked', done => {
+ calloutComponent.$el.querySelector('#dismiss-callout-btn').click();
+
+ Vue.nextTick(() => {
+ expect(calloutComponent.calloutDismissed).toBe(true);
+ done();
+ });
+ });
+
+ it('#dismissCallout updates calloutDismissed', done => {
+ calloutComponent.dismissCallout();
+
+ Vue.nextTick(() => {
+ expect(calloutComponent.calloutDismissed).toBe(true);
+ done();
+ });
+ });
+
+ it('is hidden when close button is clicked', done => {
+ calloutComponent.$el.querySelector('#dismiss-callout-btn').click();
+
+ Vue.nextTick(() => {
+ expect(calloutComponent.$el.childNodes.length).toBe(0);
+ done();
+ });
+ });
+ });
+});
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 9c292fa0f2b..1f7eec567b8 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
@@ -23,6 +23,7 @@ const defaultProps = {
lfsEnabled: true,
emailsDisabled: false,
packagesEnabled: true,
+ showDefaultAwardEmojis: true,
},
canDisableEmails: true,
canChangeVisibilityLevel: true,
@@ -57,9 +58,6 @@ describe('Settings Panel', () => {
return mountFn(settingsPanel, {
propsData,
- provide: {
- glFeatures: { metricsDashboardVisibilitySwitchingAvailable: true },
- },
});
};
@@ -477,6 +475,18 @@ describe('Settings Panel', () => {
});
});
+ describe('Default award emojis', () => {
+ it('should show the "Show default award emojis" input', () => {
+ return wrapper.vm.$nextTick(() => {
+ expect(
+ wrapper
+ .find('input[name="project[project_setting_attributes][show_default_award_emojis]"]')
+ .exists(),
+ ).toBe(true);
+ });
+ });
+ });
+
describe('Metrics dashboard', () => {
it('should show the metrics dashboard access toggle', () => {
return wrapper.vm.$nextTick(() => {
@@ -489,15 +499,22 @@ describe('Settings Panel', () => {
.find('[name="project[project_feature_attributes][metrics_dashboard_access_level]"]')
.setValue(visibilityOptions.PUBLIC);
- expect(wrapper.vm.metricsAccessLevel).toBe(visibilityOptions.PUBLIC);
+ expect(wrapper.vm.metricsDashboardAccessLevel).toBe(visibilityOptions.PUBLIC);
});
it('should contain help text', () => {
- wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PRIVATE });
-
expect(wrapper.find({ ref: 'metrics-visibility-settings' }).props().helpText).toEqual(
'With Metrics Dashboard you can visualize this project performance metrics',
);
});
+
+ it('should disable the metrics visibility dropdown when the project visibility level changes to private', () => {
+ wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PRIVATE });
+
+ const metricsSettingsRow = wrapper.find({ ref: 'metrics-visibility-settings' });
+
+ expect(wrapper.vm.metricsOptionsDropdownEnabled).toBe(true);
+ expect(metricsSettingsRow.find('select').attributes('disabled')).toEqual('disabled');
+ });
});
});
diff --git a/spec/javascripts/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
index 1809e92e1d9..1809e92e1d9 100644
--- a/spec/javascripts/pages/sessions/new/preserve_url_fragment_spec.js
+++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
new file mode 100644
index 00000000000..12c6fab9c41
--- /dev/null
+++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
@@ -0,0 +1,97 @@
+import Api from '~/api';
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import PipelinesFilteredSearch from '~/pipelines/components/pipelines_filtered_search.vue';
+import {
+ users,
+ mockSearch,
+ pipelineWithStages,
+ branches,
+ mockBranchesAfterMap,
+} from '../mock_data';
+import { GlFilteredSearch } from '@gitlab/ui';
+
+describe('Pipelines filtered search', () => {
+ let wrapper;
+ let mock;
+
+ const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
+ const getSearchToken = type =>
+ findFilteredSearch()
+ .props('availableTokens')
+ .find(token => token.type === type);
+
+ const createComponent = () => {
+ wrapper = mount(PipelinesFilteredSearch, {
+ propsData: {
+ pipelines: [pipelineWithStages],
+ projectId: '21',
+ },
+ attachToDocument: true,
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
+ jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches });
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('displays UI elements', () => {
+ expect(wrapper.isVueInstance()).toBe(true);
+ expect(wrapper.isEmpty()).toBe(false);
+
+ expect(findFilteredSearch().exists()).toBe(true);
+ });
+
+ it('displays search tokens', () => {
+ expect(getSearchToken('username')).toMatchObject({
+ type: 'username',
+ icon: 'user',
+ title: 'Trigger author',
+ unique: true,
+ triggerAuthors: users,
+ projectId: '21',
+ operators: [expect.objectContaining({ value: '=' })],
+ });
+
+ expect(getSearchToken('ref')).toMatchObject({
+ type: 'ref',
+ icon: 'branch',
+ title: 'Branch name',
+ unique: true,
+ branches: mockBranchesAfterMap,
+ projectId: '21',
+ operators: [expect.objectContaining({ value: '=' })],
+ });
+ });
+
+ it('fetches and sets project users', () => {
+ expect(Api.projectUsers).toHaveBeenCalled();
+
+ expect(wrapper.vm.projectUsers).toEqual(users);
+ });
+
+ it('fetches and sets branches', () => {
+ expect(Api.branches).toHaveBeenCalled();
+
+ expect(wrapper.vm.projectBranches).toEqual(mockBranchesAfterMap);
+ });
+
+ it('emits filterPipelines on submit with correct filter', () => {
+ findFilteredSearch().vm.$emit('submit', mockSearch);
+
+ expect(wrapper.emitted('filterPipelines')).toBeTruthy();
+ expect(wrapper.emitted('filterPipelines')[0]).toEqual([mockSearch]);
+ });
+});
diff --git a/spec/frontend/pipelines/graph/stage_column_component_spec.js b/spec/frontend/pipelines/graph/stage_column_component_spec.js
index 88e56eee1d6..d32534326c5 100644
--- a/spec/frontend/pipelines/graph/stage_column_component_spec.js
+++ b/spec/frontend/pipelines/graph/stage_column_component_spec.js
@@ -26,7 +26,7 @@ describe('stage column component', () => {
beforeEach(() => {
const mockGroups = [];
for (let i = 0; i < 3; i += 1) {
- const mockedJob = Object.assign({}, mockJob);
+ const mockedJob = { ...mockJob };
mockedJob.id += i;
mockGroups.push(mockedJob);
}
diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js
new file mode 100644
index 00000000000..1c3a6c545a0
--- /dev/null
+++ b/spec/frontend/pipelines/header_component_spec.js
@@ -0,0 +1,116 @@
+import { shallowMount } from '@vue/test-utils';
+import HeaderComponent from '~/pipelines/components/header_component.vue';
+import CiHeader from '~/vue_shared/components/header_ci_component.vue';
+import eventHub from '~/pipelines/event_hub';
+import { GlModal } from '@gitlab/ui';
+
+describe('Pipeline details header', () => {
+ let wrapper;
+ let glModalDirective;
+
+ const threeWeeksAgo = new Date();
+ threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+
+ const findDeleteModal = () => wrapper.find(GlModal);
+
+ const defaultProps = {
+ pipeline: {
+ details: {
+ status: {
+ group: 'failed',
+ icon: 'status_failed',
+ label: 'failed',
+ text: 'failed',
+ details_path: 'path',
+ },
+ },
+ id: 123,
+ created_at: threeWeeksAgo.toISOString(),
+ user: {
+ web_url: 'path',
+ name: 'Foo',
+ username: 'foobar',
+ email: 'foo@bar.com',
+ avatar_url: 'link',
+ },
+ retry_path: 'retry',
+ cancel_path: 'cancel',
+ delete_path: 'delete',
+ },
+ isLoading: false,
+ };
+
+ const createComponent = (props = {}) => {
+ glModalDirective = jest.fn();
+
+ wrapper = shallowMount(HeaderComponent, {
+ propsData: {
+ ...props,
+ },
+ directives: {
+ glModal: {
+ bind(el, { value }) {
+ glModalDirective(value);
+ },
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(eventHub, '$emit');
+
+ createComponent(defaultProps);
+ });
+
+ afterEach(() => {
+ eventHub.$off();
+
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('should render provided pipeline info', () => {
+ expect(wrapper.find(CiHeader).props()).toMatchObject({
+ status: defaultProps.pipeline.details.status,
+ itemId: defaultProps.pipeline.id,
+ time: defaultProps.pipeline.created_at,
+ user: defaultProps.pipeline.user,
+ });
+ });
+
+ describe('action buttons', () => {
+ it('should not trigger eventHub when nothing happens', () => {
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ });
+
+ it('should call postAction when retry button action is clicked', () => {
+ wrapper.find('.js-retry-button').vm.$emit('click');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry');
+ });
+
+ it('should call postAction when cancel button action is clicked', () => {
+ wrapper.find('.js-btn-cancel-pipeline').vm.$emit('click');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel');
+ });
+
+ it('does not show delete modal', () => {
+ expect(findDeleteModal()).not.toBeVisible();
+ });
+
+ describe('when delete button action is clicked', () => {
+ it('displays delete modal', () => {
+ expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID);
+ expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID);
+ });
+
+ it('should call delete when modal is submitted', () => {
+ findDeleteModal().vm.$emit('ok');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/linked_pipelines_mock.json b/spec/frontend/pipelines/linked_pipelines_mock.json
new file mode 100644
index 00000000000..8ad19ef4865
--- /dev/null
+++ b/spec/frontend/pipelines/linked_pipelines_mock.json
@@ -0,0 +1,3536 @@
+{
+ "id": 23211253,
+ "user": {
+ "id": 3585,
+ "name": "Achilleas Pipinellis",
+ "username": "axil",
+ "state": "active",
+ "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png",
+ "web_url": "https://gitlab.com/axil",
+ "status_tooltip_html": "\u003cspan class=\"user-status-emoji has-tooltip\" title=\"I like pizza\" data-html=\"true\" data-placement=\"top\"\u003e\u003cgl-emoji title=\"slice of pizza\" data-name=\"pizza\" data-unicode-version=\"6.0\"\u003e🍕\u003c/gl-emoji\u003e\u003c/span\u003e",
+ "path": "/axil"
+ },
+ "active": false,
+ "coverage": null,
+ "source": "push",
+ "created_at": "2018-06-05T11:31:30.452Z",
+ "updated_at": "2018-10-31T16:35:31.305Z",
+ "path": "/gitlab-org/gitlab-runner/pipelines/23211253",
+ "flags": {
+ "latest": false,
+ "stuck": false,
+ "auto_devops": false,
+ "merge_request": false,
+ "yaml_errors": false,
+ "retryable": false,
+ "cancelable": false,
+ "failure_reason": false
+ },
+ "details": {
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "duration": 53,
+ "finished_at": "2018-10-31T16:35:31.299Z",
+ "stages": [
+ {
+ "name": "prebuild",
+ "title": "prebuild: passed",
+ "groups": [
+ {
+ "name": "review-docs-deploy",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "manual play action",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 72469032,
+ "name": "review-docs-deploy",
+ "started": "2018-10-31T16:34:58.778Z",
+ "archived": false,
+ "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
+ "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/retry",
+ "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-06-05T11:31:30.495Z",
+ "updated_at": "2018-10-31T16:35:31.251Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "manual play action",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild",
+ "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=prebuild"
+ },
+ {
+ "name": "test",
+ "title": "test: passed",
+ "groups": [
+ {
+ "name": "docs check links",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 72469033,
+ "name": "docs check links",
+ "started": "2018-06-05T11:31:33.240Z",
+ "archived": false,
+ "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
+ "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-06-05T11:31:30.627Z",
+ "updated_at": "2018-06-05T11:31:54.363Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#test",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/gitlab-org/gitlab-runner/pipelines/23211253#test",
+ "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=test"
+ },
+ {
+ "name": "cleanup",
+ "title": "cleanup: skipped",
+ "groups": [
+ {
+ "name": "review-docs-cleanup",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual stop action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "stop",
+ "title": "Stop",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
+ "method": "post",
+ "button_title": "Stop this environment"
+ }
+ },
+ "jobs": [
+ {
+ "id": 72469034,
+ "name": "review-docs-cleanup",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
+ "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-06-05T11:31:30.760Z",
+ "updated_at": "2018-06-05T11:31:56.037Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual stop action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "stop",
+ "title": "Stop",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
+ "method": "post",
+ "button_title": "Stop this environment"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup",
+ "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=cleanup"
+ }
+ ],
+ "artifacts": [],
+ "manual_actions": [
+ {
+ "name": "review-docs-cleanup",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "review-docs-deploy",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
+ "playable": true,
+ "scheduled": false
+ }
+ ],
+ "scheduled_actions": []
+ },
+ "ref": {
+ "name": "docs/add-development-guide-to-readme",
+ "path": "/gitlab-org/gitlab-runner/commits/docs/add-development-guide-to-readme",
+ "tag": false,
+ "branch": true,
+ "merge_request": false
+ },
+ "commit": {
+ "id": "8083eb0a920572214d0dccedd7981f05d535ad46",
+ "short_id": "8083eb0a",
+ "title": "Add link to development guide in readme",
+ "created_at": "2018-06-05T11:30:48.000Z",
+ "parent_ids": ["1d7cf79b5a1a2121b9474ac20d61c1b8f621289d"],
+ "message": "Add link to development guide in readme\n\nCloses https://gitlab.com/gitlab-org/gitlab-runner/issues/3122\n",
+ "author_name": "Achilleas Pipinellis",
+ "author_email": "axil@gitlab.com",
+ "authored_date": "2018-06-05T11:30:48.000Z",
+ "committer_name": "Achilleas Pipinellis",
+ "committer_email": "axil@gitlab.com",
+ "committed_date": "2018-06-05T11:30:48.000Z",
+ "author": {
+ "id": 3585,
+ "name": "Achilleas Pipinellis",
+ "username": "axil",
+ "state": "active",
+ "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png",
+ "web_url": "https://gitlab.com/axil",
+ "status_tooltip_html": null,
+ "path": "/axil"
+ },
+ "author_gravatar_url": "https://secure.gravatar.com/avatar/1d37af00eec153a8333a4ce18e9aea41?s=80\u0026d=identicon",
+ "commit_url": "https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46",
+ "commit_path": "/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46"
+ },
+ "project": {
+ "id": 1794617
+ },
+ "triggered_by": {
+ "id": 12,
+ "user": {
+ "id": 376774,
+ "name": "Alessio Caiazza",
+ "username": "nolith",
+ "state": "active",
+ "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
+ "web_url": "https://gitlab.com/nolith",
+ "status_tooltip_html": null,
+ "path": "/nolith"
+ },
+ "active": false,
+ "coverage": null,
+ "source": "pipeline",
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "details": {
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "duration": 118,
+ "finished_at": "2018-10-31T16:41:40.615Z",
+ "stages": [
+ {
+ "name": "build-images",
+ "title": "build-images: skipped",
+ "groups": [
+ {
+ "name": "image:bootstrap",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 11421321982853,
+ "name": "image:bootstrap",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.704Z",
+ "updated_at": "2018-10-31T16:35:24.118Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:builder-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 1149822131854,
+ "name": "image:builder-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.728Z",
+ "updated_at": "2018-10-31T16:35:24.070Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 11498285523424,
+ "name": "image:nginx-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.753Z",
+ "updated_at": "2018-10-31T16:35:24.033Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images"
+ },
+ {
+ "name": "build",
+ "title": "build: failed",
+ "groups": [
+ {
+ "name": "compile_dev",
+ "size": 1,
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 1149846949786,
+ "name": "compile_dev",
+ "started": "2018-10-31T16:39:41.598Z",
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:39:41.138Z",
+ "updated_at": "2018-10-31T16:41:40.072Z",
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "recoverable": false
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build"
+ },
+ {
+ "name": "deploy",
+ "title": "deploy: skipped",
+ "groups": [
+ {
+ "name": "review",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 11498282342357,
+ "name": "review",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.805Z",
+ "updated_at": "2018-10-31T16:41:40.569Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ },
+ {
+ "name": "review_stop",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 114982858,
+ "name": "review_stop",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.840Z",
+ "updated_at": "2018-10-31T16:41:40.480Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy"
+ }
+ ],
+ "artifacts": [],
+ "manual_actions": [
+ {
+ "name": "image:bootstrap",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:builder-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "review_stop",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play",
+ "playable": false,
+ "scheduled": false
+ }
+ ],
+ "scheduled_actions": []
+ },
+ "project": {
+ "id": 1794617,
+ "name": "Test",
+ "full_path": "/gitlab-com/gitlab-docs",
+ "full_name": "GitLab.com / GitLab Docs"
+ },
+ "triggered_by": {
+ "id": 349932310342451,
+ "user": {
+ "id": 376774,
+ "name": "Alessio Caiazza",
+ "username": "nolith",
+ "state": "active",
+ "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
+ "web_url": "https://gitlab.com/nolith",
+ "status_tooltip_html": null,
+ "path": "/nolith"
+ },
+ "active": false,
+ "coverage": null,
+ "source": "pipeline",
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "details": {
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "duration": 118,
+ "finished_at": "2018-10-31T16:41:40.615Z",
+ "stages": [
+ {
+ "name": "build-images",
+ "title": "build-images: skipped",
+ "groups": [
+ {
+ "name": "image:bootstrap",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 11421321982853,
+ "name": "image:bootstrap",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.704Z",
+ "updated_at": "2018-10-31T16:35:24.118Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:builder-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 1149822131854,
+ "name": "image:builder-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.728Z",
+ "updated_at": "2018-10-31T16:35:24.070Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 11498285523424,
+ "name": "image:nginx-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.753Z",
+ "updated_at": "2018-10-31T16:35:24.033Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images"
+ },
+ {
+ "name": "build",
+ "title": "build: failed",
+ "groups": [
+ {
+ "name": "compile_dev",
+ "size": 1,
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 1149846949786,
+ "name": "compile_dev",
+ "started": "2018-10-31T16:39:41.598Z",
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:39:41.138Z",
+ "updated_at": "2018-10-31T16:41:40.072Z",
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "recoverable": false
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build"
+ },
+ {
+ "name": "deploy",
+ "title": "deploy: skipped",
+ "groups": [
+ {
+ "name": "review",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 11498282342357,
+ "name": "review",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.805Z",
+ "updated_at": "2018-10-31T16:41:40.569Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ },
+ {
+ "name": "review_stop",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 114982858,
+ "name": "review_stop",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.840Z",
+ "updated_at": "2018-10-31T16:41:40.480Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy"
+ }
+ ],
+ "artifacts": [],
+ "manual_actions": [
+ {
+ "name": "image:bootstrap",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:builder-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "review_stop",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play",
+ "playable": false,
+ "scheduled": false
+ }
+ ],
+ "scheduled_actions": []
+ },
+ "project": {
+ "id": 1794617,
+ "name": "GitLab Docs",
+ "full_path": "/gitlab-com/gitlab-docs",
+ "full_name": "GitLab.com / GitLab Docs"
+ }
+ },
+ "triggered": []
+ },
+ "triggered": [
+ {
+ "id": 34993051,
+ "user": {
+ "id": 376774,
+ "name": "Alessio Caiazza",
+ "username": "nolith",
+ "state": "active",
+ "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
+ "web_url": "https://gitlab.com/nolith",
+ "status_tooltip_html": null,
+ "path": "/nolith"
+ },
+ "active": false,
+ "coverage": null,
+ "source": "pipeline",
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "details": {
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "duration": 118,
+ "finished_at": "2018-10-31T16:41:40.615Z",
+ "stages": [
+ {
+ "name": "build-images",
+ "title": "build-images: skipped",
+ "groups": [
+ {
+ "name": "image:bootstrap",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 114982853,
+ "name": "image:bootstrap",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.704Z",
+ "updated_at": "2018-10-31T16:35:24.118Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:builder-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 114982854,
+ "name": "image:builder-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.728Z",
+ "updated_at": "2018-10-31T16:35:24.070Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 114982855,
+ "name": "image:nginx-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.753Z",
+ "updated_at": "2018-10-31T16:35:24.033Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images"
+ },
+ {
+ "name": "build",
+ "title": "build: failed",
+ "groups": [
+ {
+ "name": "compile_dev",
+ "size": 1,
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 114984694,
+ "name": "compile_dev",
+ "started": "2018-10-31T16:39:41.598Z",
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:39:41.138Z",
+ "updated_at": "2018-10-31T16:41:40.072Z",
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "recoverable": false
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build"
+ },
+ {
+ "name": "deploy",
+ "title": "deploy: skipped",
+ "groups": [
+ {
+ "name": "review",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 114982857,
+ "name": "review",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.805Z",
+ "updated_at": "2018-10-31T16:41:40.569Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ },
+ {
+ "name": "review_stop",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 114982858,
+ "name": "review_stop",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.840Z",
+ "updated_at": "2018-10-31T16:41:40.480Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy"
+ }
+ ],
+ "artifacts": [],
+ "manual_actions": [
+ {
+ "name": "image:bootstrap",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:builder-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "review_stop",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play",
+ "playable": false,
+ "scheduled": false
+ }
+ ],
+ "scheduled_actions": []
+ },
+ "project": {
+ "id": 1794617,
+ "name": "GitLab Docs",
+ "full_path": "/gitlab-com/gitlab-docs",
+ "full_name": "GitLab.com / GitLab Docs"
+ },
+ "triggered": [{}]
+ },
+ {
+ "id": 34993052,
+ "user": {
+ "id": 376774,
+ "name": "Alessio Caiazza",
+ "username": "nolith",
+ "state": "active",
+ "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
+ "web_url": "https://gitlab.com/nolith",
+ "status_tooltip_html": null,
+ "path": "/nolith"
+ },
+ "active": false,
+ "coverage": null,
+ "source": "pipeline",
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "details": {
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "duration": 118,
+ "finished_at": "2018-10-31T16:41:40.615Z",
+ "stages": [
+ {
+ "name": "build-images",
+ "title": "build-images: skipped",
+ "groups": [
+ {
+ "name": "image:bootstrap",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 114982853,
+ "name": "image:bootstrap",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.704Z",
+ "updated_at": "2018-10-31T16:35:24.118Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:builder-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 114982854,
+ "name": "image:builder-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.728Z",
+ "updated_at": "2018-10-31T16:35:24.070Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 1224982855,
+ "name": "image:nginx-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.753Z",
+ "updated_at": "2018-10-31T16:35:24.033Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images"
+ },
+ {
+ "name": "build",
+ "title": "build: failed",
+ "groups": [
+ {
+ "name": "compile_dev",
+ "size": 1,
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 1123984694,
+ "name": "compile_dev",
+ "started": "2018-10-31T16:39:41.598Z",
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:39:41.138Z",
+ "updated_at": "2018-10-31T16:41:40.072Z",
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "recoverable": false
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build"
+ },
+ {
+ "name": "deploy",
+ "title": "deploy: skipped",
+ "groups": [
+ {
+ "name": "review",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 1143232982857,
+ "name": "review",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.805Z",
+ "updated_at": "2018-10-31T16:41:40.569Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ },
+ {
+ "name": "review_stop",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 114921313182858,
+ "name": "review_stop",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.840Z",
+ "updated_at": "2018-10-31T16:41:40.480Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy"
+ }
+ ],
+ "artifacts": [],
+ "manual_actions": [
+ {
+ "name": "image:bootstrap",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:builder-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "review_stop",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play",
+ "playable": false,
+ "scheduled": false
+ }
+ ],
+ "scheduled_actions": []
+ },
+ "project": {
+ "id": 1794617,
+ "name": "GitLab Docs",
+ "full_path": "/gitlab-com/gitlab-docs",
+ "full_name": "GitLab.com / GitLab Docs"
+ },
+ "triggered": [
+ {
+ "id": 26,
+ "user": null,
+ "active": false,
+ "coverage": null,
+ "source": "push",
+ "created_at": "2019-01-06T17:48:37.599Z",
+ "updated_at": "2019-01-06T17:48:38.371Z",
+ "path": "/h5bp/html5-boilerplate/pipelines/26",
+ "flags": {
+ "latest": true,
+ "stuck": false,
+ "auto_devops": false,
+ "merge_request": false,
+ "yaml_errors": false,
+ "retryable": true,
+ "cancelable": false,
+ "failure_reason": false
+ },
+ "details": {
+ "status": {
+ "icon": "status_warning",
+ "text": "passed",
+ "label": "passed with warnings",
+ "group": "success-with-warnings",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/pipelines/26",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "duration": null,
+ "finished_at": "2019-01-06T17:48:38.370Z",
+ "stages": [
+ {
+ "name": "build",
+ "title": "build: passed",
+ "groups": [
+ {
+ "name": "build:linux",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/526",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/526/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 526,
+ "name": "build:linux",
+ "started": "2019-01-06T08:48:20.236Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/526",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/526/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.806Z",
+ "updated_at": "2019-01-06T17:48:37.806Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/526",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/526/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "build:osx",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/527",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/527/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 527,
+ "name": "build:osx",
+ "started": "2019-01-06T07:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/527",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/527/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.846Z",
+ "updated_at": "2019-01-06T17:48:37.846Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/527",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/527/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/pipelines/26#build",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/h5bp/html5-boilerplate/pipelines/26#build",
+ "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=build"
+ },
+ {
+ "name": "test",
+ "title": "test: passed with warnings",
+ "groups": [
+ {
+ "name": "jenkins",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": null,
+ "group": "success",
+ "tooltip": null,
+ "has_details": false,
+ "details_path": null,
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "jobs": [
+ {
+ "id": 546,
+ "name": "jenkins",
+ "started": "2019-01-06T11:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/546",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.359Z",
+ "updated_at": "2019-01-06T17:48:38.359Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": null,
+ "group": "success",
+ "tooltip": null,
+ "has_details": false,
+ "details_path": null,
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ }
+ }
+ ]
+ },
+ {
+ "name": "rspec:linux",
+ "size": 3,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": false,
+ "details_path": null,
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "jobs": [
+ {
+ "id": 528,
+ "name": "rspec:linux 0 3",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/528",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.885Z",
+ "updated_at": "2019-01-06T17:48:37.885Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/528",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ },
+ {
+ "id": 529,
+ "name": "rspec:linux 1 3",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/529",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/529/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.907Z",
+ "updated_at": "2019-01-06T17:48:37.907Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/529",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/529/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ },
+ {
+ "id": 530,
+ "name": "rspec:linux 2 3",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/530",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/530/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.927Z",
+ "updated_at": "2019-01-06T17:48:37.927Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/530",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/530/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "rspec:osx",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/535",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/535/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 535,
+ "name": "rspec:osx",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/535",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/535/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.018Z",
+ "updated_at": "2019-01-06T17:48:38.018Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/535",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/535/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "rspec:windows",
+ "size": 3,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": false,
+ "details_path": null,
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "jobs": [
+ {
+ "id": 531,
+ "name": "rspec:windows 0 3",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/531",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/531/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.944Z",
+ "updated_at": "2019-01-06T17:48:37.944Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/531",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/531/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ },
+ {
+ "id": 532,
+ "name": "rspec:windows 1 3",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/532",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/532/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.962Z",
+ "updated_at": "2019-01-06T17:48:37.962Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/532",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/532/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ },
+ {
+ "id": 534,
+ "name": "rspec:windows 2 3",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/534",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/534/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.999Z",
+ "updated_at": "2019-01-06T17:48:37.999Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/534",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/534/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "spinach:linux",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/536",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/536/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 536,
+ "name": "spinach:linux",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/536",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/536/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.050Z",
+ "updated_at": "2019-01-06T17:48:38.050Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/536",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/536/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "spinach:osx",
+ "size": 1,
+ "status": {
+ "icon": "status_warning",
+ "text": "failed",
+ "label": "failed (allowed to fail)",
+ "group": "failed-with-warnings",
+ "tooltip": "failed - (unknown failure) (allowed to fail)",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/537",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/537/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 537,
+ "name": "spinach:osx",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/537",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/537/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.069Z",
+ "updated_at": "2019-01-06T17:48:38.069Z",
+ "status": {
+ "icon": "status_warning",
+ "text": "failed",
+ "label": "failed (allowed to fail)",
+ "group": "failed-with-warnings",
+ "tooltip": "failed - (unknown failure) (allowed to fail)",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/537",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/537/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "callout_message": "There is an unknown failure, please try again",
+ "recoverable": true
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_warning",
+ "text": "passed",
+ "label": "passed with warnings",
+ "group": "success-with-warnings",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/pipelines/26#test",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/h5bp/html5-boilerplate/pipelines/26#test",
+ "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=test"
+ },
+ {
+ "name": "security",
+ "title": "security: passed",
+ "groups": [
+ {
+ "name": "container_scanning",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/541",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/541/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 541,
+ "name": "container_scanning",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/541",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/541/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.186Z",
+ "updated_at": "2019-01-06T17:48:38.186Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/541",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/541/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "dast",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/538",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/538/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 538,
+ "name": "dast",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/538",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/538/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.087Z",
+ "updated_at": "2019-01-06T17:48:38.087Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/538",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/538/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "dependency_scanning",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/540",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/540/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 540,
+ "name": "dependency_scanning",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/540",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/540/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.153Z",
+ "updated_at": "2019-01-06T17:48:38.153Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/540",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/540/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "sast",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/539",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/539/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 539,
+ "name": "sast",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/539",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/539/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.121Z",
+ "updated_at": "2019-01-06T17:48:38.121Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/539",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/539/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/pipelines/26#security",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/h5bp/html5-boilerplate/pipelines/26#security",
+ "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=security"
+ },
+ {
+ "name": "deploy",
+ "title": "deploy: passed",
+ "groups": [
+ {
+ "name": "production",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/544",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 544,
+ "name": "production",
+ "started": null,
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/544",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.313Z",
+ "updated_at": "2019-01-06T17:48:38.313Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/544",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ },
+ {
+ "name": "staging",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/542",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/542/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 542,
+ "name": "staging",
+ "started": "2019-01-06T11:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/542",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/542/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.219Z",
+ "updated_at": "2019-01-06T17:48:38.219Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/542",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/542/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "stop staging",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/543",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 543,
+ "name": "stop staging",
+ "started": null,
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/543",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.283Z",
+ "updated_at": "2019-01-06T17:48:38.283Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/543",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/pipelines/26#deploy",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/h5bp/html5-boilerplate/pipelines/26#deploy",
+ "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=deploy"
+ },
+ {
+ "name": "notify",
+ "title": "notify: passed",
+ "groups": [
+ {
+ "name": "slack",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "manual play action",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/545",
+ "illustration": {
+ "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/h5bp/html5-boilerplate/-/jobs/545/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 545,
+ "name": "slack",
+ "started": null,
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/545",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/545/retry",
+ "play_path": "/h5bp/html5-boilerplate/-/jobs/545/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.341Z",
+ "updated_at": "2019-01-06T17:48:38.341Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "manual play action",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/545",
+ "illustration": {
+ "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/h5bp/html5-boilerplate/-/jobs/545/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/pipelines/26#notify",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/h5bp/html5-boilerplate/pipelines/26#notify",
+ "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=notify"
+ }
+ ],
+ "artifacts": [
+ {
+ "name": "build:linux",
+ "expired": null,
+ "expire_at": null,
+ "path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/download",
+ "browse_path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/browse"
+ },
+ {
+ "name": "build:osx",
+ "expired": null,
+ "expire_at": null,
+ "path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/download",
+ "browse_path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/browse"
+ }
+ ],
+ "manual_actions": [
+ {
+ "name": "stop staging",
+ "path": "/h5bp/html5-boilerplate/-/jobs/543/play",
+ "playable": false,
+ "scheduled": false
+ },
+ {
+ "name": "production",
+ "path": "/h5bp/html5-boilerplate/-/jobs/544/play",
+ "playable": false,
+ "scheduled": false
+ },
+ {
+ "name": "slack",
+ "path": "/h5bp/html5-boilerplate/-/jobs/545/play",
+ "playable": true,
+ "scheduled": false
+ }
+ ],
+ "scheduled_actions": []
+ },
+ "ref": {
+ "name": "master",
+ "path": "/h5bp/html5-boilerplate/commits/master",
+ "tag": false,
+ "branch": true,
+ "merge_request": false
+ },
+ "commit": {
+ "id": "bad98c453eab56d20057f3929989251d45cd1a8b",
+ "short_id": "bad98c45",
+ "title": "remove instances of shrink-to-fit=no (#2103)",
+ "created_at": "2018-12-17T20:52:18.000Z",
+ "parent_ids": ["49130f6cfe9ff1f749015d735649a2bc6f66cf3a"],
+ "message": "remove instances of shrink-to-fit=no (#2103)\n\ncloses #2102\r\n\r\nPer my findings, the need for it as a default was rectified with the release of iOS 9.3, where the viewport no longer shrunk to accommodate overflow, as was introduced in iOS 9.",
+ "author_name": "Scott O'Hara",
+ "author_email": "scottaohara@users.noreply.github.com",
+ "authored_date": "2018-12-17T20:52:18.000Z",
+ "committer_name": "Rob Larsen",
+ "committer_email": "rob@drunkenfist.com",
+ "committed_date": "2018-12-17T20:52:18.000Z",
+ "author": null,
+ "author_gravatar_url": "https://www.gravatar.com/avatar/6d597df7cf998d16cbe00ccac063b31e?s=80\u0026d=identicon",
+ "commit_url": "http://localhost:3001/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b",
+ "commit_path": "/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b"
+ },
+ "retry_path": "/h5bp/html5-boilerplate/pipelines/26/retry",
+ "triggered_by": {
+ "id": 4,
+ "user": null,
+ "active": false,
+ "coverage": null,
+ "source": "push",
+ "path": "/gitlab-org/gitlab-test/pipelines/4",
+ "details": {
+ "status": {
+ "icon": "status_warning",
+ "text": "passed",
+ "label": "passed with warnings",
+ "group": "success-with-warnings",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-test/pipelines/4",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ }
+ },
+ "project": {
+ "id": 1,
+ "name": "Gitlab Test",
+ "full_path": "/gitlab-org/gitlab-test",
+ "full_name": "Gitlab Org / Gitlab Test"
+ }
+ },
+ "triggered": [],
+ "project": {
+ "id": 1794617,
+ "name": "GitLab Docs",
+ "full_path": "/gitlab-com/gitlab-docs",
+ "full_name": "GitLab.com / GitLab Docs"
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js
new file mode 100644
index 00000000000..37c1e471415
--- /dev/null
+++ b/spec/frontend/pipelines/mock_data.js
@@ -0,0 +1,568 @@
+export const pipelineWithStages = {
+ id: 20333396,
+ user: {
+ id: 128633,
+ name: 'Rémy Coutable',
+ username: 'rymai',
+ state: 'active',
+ avatar_url:
+ 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon',
+ web_url: 'https://gitlab.com/rymai',
+ path: '/rymai',
+ },
+ active: true,
+ coverage: '58.24',
+ source: 'push',
+ created_at: '2018-04-11T14:04:53.881Z',
+ updated_at: '2018-04-11T14:05:00.792Z',
+ path: '/gitlab-org/gitlab/pipelines/20333396',
+ flags: {
+ latest: true,
+ stuck: false,
+ auto_devops: false,
+ yaml_errors: false,
+ retryable: false,
+ cancelable: true,
+ failure_reason: false,
+ },
+ details: {
+ status: {
+ icon: 'status_running',
+ text: 'running',
+ label: 'running',
+ group: 'running',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico',
+ },
+ duration: null,
+ finished_at: null,
+ stages: [
+ {
+ name: 'build',
+ title: 'build: skipped',
+ status: {
+ icon: 'status_skipped',
+ text: 'skipped',
+ label: 'skipped',
+ group: 'skipped',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396#build',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_skipped-a2eee568a5bffdb494050c7b62dde241de9189280836288ac8923d369f16222d.ico',
+ },
+ path: '/gitlab-org/gitlab/pipelines/20333396#build',
+ dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=build',
+ },
+ {
+ name: 'prepare',
+ title: 'prepare: passed',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396#prepare',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_success-26f59841becbef8c6fe414e9e74471d8bfd6a91b5855c19fe7f5923a40a7da47.ico',
+ },
+ path: '/gitlab-org/gitlab/pipelines/20333396#prepare',
+ dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=prepare',
+ },
+ {
+ name: 'test',
+ title: 'test: running',
+ status: {
+ icon: 'status_running',
+ text: 'running',
+ label: 'running',
+ group: 'running',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396#test',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico',
+ },
+ path: '/gitlab-org/gitlab/pipelines/20333396#test',
+ dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=test',
+ },
+ {
+ name: 'post-test',
+ title: 'post-test: created',
+ status: {
+ icon: 'status_created',
+ text: 'created',
+ label: 'created',
+ group: 'created',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396#post-test',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico',
+ },
+ path: '/gitlab-org/gitlab/pipelines/20333396#post-test',
+ dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-test',
+ },
+ {
+ name: 'pages',
+ title: 'pages: created',
+ status: {
+ icon: 'status_created',
+ text: 'created',
+ label: 'created',
+ group: 'created',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396#pages',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico',
+ },
+ path: '/gitlab-org/gitlab/pipelines/20333396#pages',
+ dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=pages',
+ },
+ {
+ name: 'post-cleanup',
+ title: 'post-cleanup: created',
+ status: {
+ icon: 'status_created',
+ text: 'created',
+ label: 'created',
+ group: 'created',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico',
+ },
+ path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup',
+ dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-cleanup',
+ },
+ ],
+ artifacts: [
+ {
+ name: 'gitlab:assets:compile',
+ expired: false,
+ expire_at: '2018-05-12T14:22:54.730Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/browse',
+ },
+ {
+ name: 'rspec-mysql 12 28',
+ expired: false,
+ expire_at: '2018-05-12T14:22:45.136Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/browse',
+ },
+ {
+ name: 'rspec-mysql 6 28',
+ expired: false,
+ expire_at: '2018-05-12T14:22:41.523Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/browse',
+ },
+ {
+ name: 'rspec-pg geo 0 1',
+ expired: false,
+ expire_at: '2018-05-12T14:22:13.287Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/browse',
+ },
+ {
+ name: 'rspec-mysql 0 28',
+ expired: false,
+ expire_at: '2018-05-12T14:22:06.834Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/browse',
+ },
+ {
+ name: 'spinach-mysql 0 2',
+ expired: false,
+ expire_at: '2018-05-12T14:21:51.409Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/browse',
+ },
+ {
+ name: 'karma',
+ expired: false,
+ expire_at: '2018-05-12T14:21:20.934Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/browse',
+ },
+ {
+ name: 'spinach-pg 0 2',
+ expired: false,
+ expire_at: '2018-05-12T14:20:01.028Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/browse',
+ },
+ {
+ name: 'spinach-pg 1 2',
+ expired: false,
+ expire_at: '2018-05-12T14:19:04.336Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/browse',
+ },
+ {
+ name: 'sast',
+ expired: null,
+ expire_at: null,
+ path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/download',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/browse',
+ },
+ {
+ name: 'code_quality',
+ expired: false,
+ expire_at: '2018-04-18T14:16:24.484Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/browse',
+ },
+ {
+ name: 'cache gems',
+ expired: null,
+ expire_at: null,
+ path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/download',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/browse',
+ },
+ {
+ name: 'dependency_scanning',
+ expired: null,
+ expire_at: null,
+ path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/download',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/browse',
+ },
+ {
+ name: 'compile-assets',
+ expired: false,
+ expire_at: '2018-04-18T14:12:07.638Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/browse',
+ },
+ {
+ name: 'setup-test-env',
+ expired: false,
+ expire_at: '2018-04-18T14:10:27.024Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/browse',
+ },
+ {
+ name: 'retrieve-tests-metadata',
+ expired: false,
+ expire_at: '2018-05-12T14:06:35.926Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/browse',
+ },
+ ],
+ manual_actions: [
+ {
+ name: 'package-and-qa',
+ path: '/gitlab-org/gitlab/-/jobs/62411330/play',
+ playable: true,
+ },
+ {
+ name: 'review-docs-deploy',
+ path: '/gitlab-org/gitlab/-/jobs/62411332/play',
+ playable: true,
+ },
+ ],
+ },
+ ref: {
+ name: 'master',
+ path: '/gitlab-org/gitlab/commits/master',
+ tag: false,
+ branch: true,
+ },
+ commit: {
+ id: 'e6a2885c503825792cb8a84a8731295e361bd059',
+ short_id: 'e6a2885c',
+ title: "Merge branch 'ce-to-ee-2018-04-11' into 'master'",
+ created_at: '2018-04-11T14:04:39.000Z',
+ parent_ids: [
+ '5d9b5118f6055f72cff1a82b88133609912f2c1d',
+ '6fdc6ee76a8062fe41b1a33f7c503334a6ebdc02',
+ ],
+ message:
+ "Merge branch 'ce-to-ee-2018-04-11' into 'master'\n\nCE upstream - 2018-04-11 12:26 UTC\n\nSee merge request gitlab-org/gitlab-ee!5326",
+ author_name: 'Rémy Coutable',
+ author_email: 'remy@rymai.me',
+ authored_date: '2018-04-11T14:04:39.000Z',
+ committer_name: 'Rémy Coutable',
+ committer_email: 'remy@rymai.me',
+ committed_date: '2018-04-11T14:04:39.000Z',
+ author: {
+ id: 128633,
+ name: 'Rémy Coutable',
+ username: 'rymai',
+ state: 'active',
+ avatar_url:
+ 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon',
+ web_url: 'https://gitlab.com/rymai',
+ path: '/rymai',
+ },
+ author_gravatar_url:
+ 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon',
+ commit_url:
+ 'https://gitlab.com/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059',
+ commit_path: '/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059',
+ },
+ cancel_path: '/gitlab-org/gitlab/pipelines/20333396/cancel',
+ triggered_by: null,
+ triggered: [],
+};
+
+export const stageReply = {
+ name: 'deploy',
+ title: 'deploy: running',
+ latest_statuses: [
+ {
+ id: 928,
+ name: 'stop staging',
+ started: false,
+ build_path: '/twitter/flight/-/jobs/928',
+ cancel_path: '/twitter/flight/-/jobs/928/cancel',
+ playable: false,
+ created_at: '2018-04-04T20:02:02.728Z',
+ updated_at: '2018-04-04T20:02:02.766Z',
+ status: {
+ icon: 'status_pending',
+ text: 'pending',
+ label: 'pending',
+ group: 'pending',
+ tooltip: 'pending',
+ has_details: true,
+ details_path: '/twitter/flight/-/jobs/928',
+ favicon:
+ '/assets/ci_favicons/dev/favicon_status_pending-db32e1faf94b9f89530ac519790920d1f18ea8f6af6cd2e0a26cd6840cacf101.ico',
+ action: {
+ icon: 'cancel',
+ title: 'Cancel',
+ path: '/twitter/flight/-/jobs/928/cancel',
+ method: 'post',
+ },
+ },
+ },
+ {
+ id: 926,
+ name: 'production',
+ started: false,
+ build_path: '/twitter/flight/-/jobs/926',
+ retry_path: '/twitter/flight/-/jobs/926/retry',
+ play_path: '/twitter/flight/-/jobs/926/play',
+ playable: true,
+ created_at: '2018-04-04T20:00:57.202Z',
+ updated_at: '2018-04-04T20:11:13.110Z',
+ status: {
+ icon: 'status_canceled',
+ text: 'canceled',
+ label: 'manual play action',
+ group: 'canceled',
+ tooltip: 'canceled',
+ has_details: true,
+ details_path: '/twitter/flight/-/jobs/926',
+ favicon:
+ '/assets/ci_favicons/dev/favicon_status_canceled-5491840b9b6feafba0bc599cbd49ee9580321dc809683856cf1b0d51532b1af6.ico',
+ action: {
+ icon: 'play',
+ title: 'Play',
+ path: '/twitter/flight/-/jobs/926/play',
+ method: 'post',
+ },
+ },
+ },
+ {
+ id: 217,
+ name: 'staging',
+ started: '2018-03-07T08:41:46.234Z',
+ build_path: '/twitter/flight/-/jobs/217',
+ retry_path: '/twitter/flight/-/jobs/217/retry',
+ playable: false,
+ created_at: '2018-03-07T14:41:58.093Z',
+ updated_at: '2018-03-07T14:41:58.093Z',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ tooltip: 'passed',
+ has_details: true,
+ details_path: '/twitter/flight/-/jobs/217',
+ favicon:
+ '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
+ action: {
+ icon: 'retry',
+ title: 'Retry',
+ path: '/twitter/flight/-/jobs/217/retry',
+ method: 'post',
+ },
+ },
+ },
+ ],
+ status: {
+ icon: 'status_running',
+ text: 'running',
+ label: 'running',
+ group: 'running',
+ tooltip: 'running',
+ has_details: true,
+ details_path: '/twitter/flight/pipelines/13#deploy',
+ favicon:
+ '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico',
+ },
+ path: '/twitter/flight/pipelines/13#deploy',
+ dropdown_path: '/twitter/flight/pipelines/13/stage.json?stage=deploy',
+};
+
+export const users = [
+ {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ web_url: 'http://192.168.1.22:3000/root',
+ },
+ {
+ id: 10,
+ name: 'Angel Spinka',
+ username: 'shalonda',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/709df1b65ad06764ee2b0edf1b49fc27?s=80\u0026d=identicon',
+ web_url: 'http://192.168.1.22:3000/shalonda',
+ },
+ {
+ id: 11,
+ name: 'Art Davis',
+ username: 'deja.green',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/bb56834c061522760e7a6dd7d431a306?s=80\u0026d=identicon',
+ web_url: 'http://192.168.1.22:3000/deja.green',
+ },
+ {
+ id: 32,
+ name: 'Arnold Mante',
+ username: 'reported_user_10',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/ab558033a82466d7905179e837d7723a?s=80\u0026d=identicon',
+ web_url: 'http://192.168.1.22:3000/reported_user_10',
+ },
+ {
+ id: 38,
+ name: 'Cher Wintheiser',
+ username: 'reported_user_16',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/2640356e8b5bc4314133090994ed162b?s=80\u0026d=identicon',
+ web_url: 'http://192.168.1.22:3000/reported_user_16',
+ },
+ {
+ id: 39,
+ name: 'Bethel Wolf',
+ username: 'reported_user_17',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/4b948694fadba4b01e4acfc06b065e8e?s=80\u0026d=identicon',
+ web_url: 'http://192.168.1.22:3000/reported_user_17',
+ },
+];
+
+export const branches = [
+ {
+ name: 'branch-1',
+ commit: {
+ id: '21fb056cc47dcf706670e6de635b1b326490ebdc',
+ short_id: '21fb056c',
+ created_at: '2020-05-07T10:58:28.000-04:00',
+ parent_ids: null,
+ title: 'Add new file',
+ message: 'Add new file',
+ author_name: 'Administrator',
+ author_email: 'admin@example.com',
+ authored_date: '2020-05-07T10:58:28.000-04:00',
+ committer_name: 'Administrator',
+ committer_email: 'admin@example.com',
+ committed_date: '2020-05-07T10:58:28.000-04:00',
+ web_url:
+ 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/21fb056cc47dcf706670e6de635b1b326490ebdc',
+ },
+ merged: false,
+ protected: false,
+ developers_can_push: false,
+ developers_can_merge: false,
+ can_push: true,
+ default: false,
+ web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-1',
+ },
+ {
+ name: 'branch-10',
+ commit: {
+ id: '66673b07efef254dab7d537f0433a40e61cf84fe',
+ short_id: '66673b07',
+ created_at: '2020-03-16T11:04:46.000-04:00',
+ parent_ids: null,
+ title: 'Update .gitlab-ci.yml',
+ message: 'Update .gitlab-ci.yml',
+ author_name: 'Administrator',
+ author_email: 'admin@example.com',
+ authored_date: '2020-03-16T11:04:46.000-04:00',
+ committer_name: 'Administrator',
+ committer_email: 'admin@example.com',
+ committed_date: '2020-03-16T11:04:46.000-04:00',
+ web_url:
+ 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe',
+ },
+ merged: false,
+ protected: false,
+ developers_can_push: false,
+ developers_can_merge: false,
+ can_push: true,
+ default: false,
+ web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-10',
+ },
+ {
+ name: 'branch-11',
+ commit: {
+ id: '66673b07efef254dab7d537f0433a40e61cf84fe',
+ short_id: '66673b07',
+ created_at: '2020-03-16T11:04:46.000-04:00',
+ parent_ids: null,
+ title: 'Update .gitlab-ci.yml',
+ message: 'Update .gitlab-ci.yml',
+ author_name: 'Administrator',
+ author_email: 'admin@example.com',
+ authored_date: '2020-03-16T11:04:46.000-04:00',
+ committer_name: 'Administrator',
+ committer_email: 'admin@example.com',
+ committed_date: '2020-03-16T11:04:46.000-04:00',
+ web_url:
+ 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe',
+ },
+ merged: false,
+ protected: false,
+ developers_can_push: false,
+ developers_can_merge: false,
+ can_push: true,
+ default: false,
+ web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-11',
+ },
+];
+
+export const mockSearch = [
+ { type: 'username', value: { data: 'root', operator: '=' } },
+ { type: 'ref', value: { data: 'master', operator: '=' } },
+];
+
+export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11'];
diff --git a/spec/frontend/pipelines/pipeline_details_mediator_spec.js b/spec/frontend/pipelines/pipeline_details_mediator_spec.js
new file mode 100644
index 00000000000..083e97666ed
--- /dev/null
+++ b/spec/frontend/pipelines/pipeline_details_mediator_spec.js
@@ -0,0 +1,36 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import PipelineMediator from '~/pipelines/pipeline_details_mediator';
+import waitForPromises from 'helpers/wait_for_promises';
+
+describe('PipelineMdediator', () => {
+ let mediator;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mediator = new PipelineMediator({ endpoint: 'foo.json' });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should set defaults', () => {
+ expect(mediator.options).toEqual({ endpoint: 'foo.json' });
+ expect(mediator.state.isLoading).toEqual(false);
+ expect(mediator.store).toBeDefined();
+ expect(mediator.service).toBeDefined();
+ });
+
+ describe('request and store data', () => {
+ it('should store received data', () => {
+ mock.onGet('foo.json').reply(200, { id: '121123' });
+ mediator.fetchPipeline();
+
+ return waitForPromises().then(() => {
+ expect(mediator.store.state.pipeline).toEqual({ id: '121123' });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js
new file mode 100644
index 00000000000..5e8d21660de
--- /dev/null
+++ b/spec/frontend/pipelines/pipelines_actions_spec.js
@@ -0,0 +1,142 @@
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { TEST_HOST } from 'spec/test_constants';
+import axios from '~/lib/utils/axios_utils';
+import PipelinesActions from '~/pipelines/components/pipelines_actions.vue';
+import { GlDeprecatedButton } from '@gitlab/ui';
+import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
+import waitForPromises from 'helpers/wait_for_promises';
+
+describe('Pipelines Actions dropdown', () => {
+ let wrapper;
+ let mock;
+
+ const createComponent = (actions = []) => {
+ wrapper = shallowMount(PipelinesActions, {
+ propsData: {
+ actions,
+ },
+ });
+ };
+
+ const findAllDropdownItems = () => wrapper.findAll(GlDeprecatedButton);
+ const findAllCountdowns = () => wrapper.findAll(GlCountdown);
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+
+ mock.restore();
+ });
+
+ describe('manual actions', () => {
+ const mockActions = [
+ {
+ name: 'stop_review',
+ path: `${TEST_HOST}/root/review-app/builds/1893/play`,
+ },
+ {
+ name: 'foo',
+ path: `${TEST_HOST}/disabled/pipeline/action`,
+ playable: false,
+ },
+ ];
+
+ beforeEach(() => {
+ createComponent(mockActions);
+ });
+
+ it('renders a dropdown with the provided actions', () => {
+ expect(findAllDropdownItems()).toHaveLength(mockActions.length);
+ });
+
+ it("renders a disabled action when it's not playable", () => {
+ expect(
+ findAllDropdownItems()
+ .at(1)
+ .attributes('disabled'),
+ ).toBe('true');
+ });
+
+ describe('on click', () => {
+ it('makes a request and toggles the loading state', () => {
+ mock.onPost(mockActions.path).reply(200);
+
+ wrapper.find(GlDeprecatedButton).vm.$emit('click');
+
+ expect(wrapper.vm.isLoading).toBe(true);
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.isLoading).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('scheduled jobs', () => {
+ const scheduledJobAction = {
+ name: 'scheduled action',
+ path: `${TEST_HOST}/scheduled/job/action`,
+ playable: true,
+ scheduled_at: '2063-04-05T00:42:00Z',
+ };
+ const expiredJobAction = {
+ name: 'expired action',
+ path: `${TEST_HOST}/expired/job/action`,
+ playable: true,
+ scheduled_at: '2018-10-05T08:23:00Z',
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime());
+ createComponent([scheduledJobAction, expiredJobAction]);
+ });
+
+ it('makes post request after confirming', () => {
+ mock.onPost(scheduledJobAction.path).reply(200);
+ jest.spyOn(window, 'confirm').mockReturnValue(true);
+
+ findAllDropdownItems()
+ .at(0)
+ .vm.$emit('click');
+
+ expect(window.confirm).toHaveBeenCalled();
+
+ return waitForPromises().then(() => {
+ expect(mock.history.post.length).toBe(1);
+ });
+ });
+
+ it('does not make post request if confirmation is cancelled', () => {
+ mock.onPost(scheduledJobAction.path).reply(200);
+ jest.spyOn(window, 'confirm').mockReturnValue(false);
+
+ findAllDropdownItems()
+ .at(0)
+ .vm.$emit('click');
+
+ expect(window.confirm).toHaveBeenCalled();
+ expect(mock.history.post.length).toBe(0);
+ });
+
+ it('displays the remaining time in the dropdown', () => {
+ expect(
+ findAllCountdowns()
+ .at(0)
+ .props('endDateString'),
+ ).toBe(scheduledJobAction.scheduled_at);
+ });
+
+ it('displays 00:00:00 for expired jobs in the dropdown', () => {
+ expect(
+ findAllCountdowns()
+ .at(1)
+ .props('endDateString'),
+ ).toBe(expiredJobAction.scheduled_at);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js
new file mode 100644
index 00000000000..a93cc8a62ab
--- /dev/null
+++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js
@@ -0,0 +1,46 @@
+import { shallowMount } from '@vue/test-utils';
+import PipelineArtifacts from '~/pipelines/components/pipelines_artifacts.vue';
+import { GlLink } from '@gitlab/ui';
+
+describe('Pipelines Artifacts dropdown', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(PipelineArtifacts, {
+ propsData: {
+ artifacts: [
+ {
+ name: 'artifact',
+ path: '/download/path',
+ },
+ {
+ name: 'artifact two',
+ path: '/download/path-two',
+ },
+ ],
+ },
+ });
+ };
+
+ const findGlLink = () => wrapper.find(GlLink);
+ const findAllGlLinks = () => wrapper.find('.dropdown-menu').findAll(GlLink);
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('should render a dropdown with all the provided artifacts', () => {
+ expect(findAllGlLinks()).toHaveLength(2);
+ });
+
+ it('should render a link with the provided path', () => {
+ expect(findGlLink().attributes('href')).toEqual('/download/path');
+
+ expect(findGlLink().text()).toContain('artifact');
+ });
+});
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
new file mode 100644
index 00000000000..2ddd2116e2c
--- /dev/null
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -0,0 +1,710 @@
+import Api from '~/api';
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import PipelinesComponent from '~/pipelines/components/pipelines.vue';
+import Store from '~/pipelines/stores/pipelines_store';
+import { pipelineWithStages, stageReply, users, mockSearch, branches } from './mock_data';
+import { RAW_TEXT_WARNING } from '~/pipelines/constants';
+import { GlFilteredSearch } from '@gitlab/ui';
+import createFlash from '~/flash';
+
+jest.mock('~/flash', () => jest.fn());
+
+describe('Pipelines', () => {
+ const jsonFixtureName = 'pipelines/pipelines.json';
+
+ preloadFixtures(jsonFixtureName);
+
+ let pipelines;
+ let wrapper;
+ let mock;
+
+ const paths = {
+ endpoint: 'twitter/flight/pipelines.json',
+ autoDevopsPath: '/help/topics/autodevops/index.md',
+ helpPagePath: '/help/ci/quick_start/README',
+ emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg',
+ errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
+ noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
+ ciLintPath: '/ci/lint',
+ resetCachePath: '/twitter/flight/settings/ci_cd/reset_cache',
+ newPipelinePath: '/twitter/flight/pipelines/new',
+ };
+
+ const noPermissions = {
+ endpoint: 'twitter/flight/pipelines.json',
+ autoDevopsPath: '/help/topics/autodevops/index.md',
+ helpPagePath: '/help/ci/quick_start/README',
+ emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg',
+ errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
+ noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
+ };
+
+ const defaultProps = {
+ hasGitlabCi: true,
+ canCreatePipeline: true,
+ ...paths,
+ };
+
+ const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
+
+ const createComponent = (props = defaultProps, methods) => {
+ wrapper = mount(PipelinesComponent, {
+ provide: { glFeatures: { filterPipelinesSearch: true } },
+ propsData: {
+ store: new Store(),
+ projectId: '21',
+ ...props,
+ },
+ methods: {
+ ...methods,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ pipelines = getJSONFixture(jsonFixtureName);
+
+ jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
+ jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ describe('With permission', () => {
+ describe('With pipelines in main tab', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
+ createComponent();
+ return waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ });
+
+ it('renders Run Pipeline link', () => {
+ expect(wrapper.find('.js-run-pipeline').attributes('href')).toBe(paths.newPipelinePath);
+ });
+
+ it('renders CI Lint link', () => {
+ expect(wrapper.find('.js-ci-lint').attributes('href')).toBe(paths.ciLintPath);
+ });
+
+ it('renders Clear Runner Cache button', () => {
+ expect(wrapper.find('.js-clear-cache').text()).toBe('Clear Runner Caches');
+ });
+
+ it('renders pipelines table', () => {
+ expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength(
+ pipelines.pipelines.length + 1,
+ );
+ });
+ });
+
+ describe('Without pipelines on main tab with CI', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, {
+ pipelines: [],
+ count: {
+ all: 0,
+ pending: 0,
+ running: 0,
+ finished: 0,
+ },
+ });
+
+ createComponent();
+
+ return waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ });
+
+ it('renders Run Pipeline link', () => {
+ expect(wrapper.find('.js-run-pipeline').attributes('href')).toEqual(paths.newPipelinePath);
+ });
+
+ it('renders CI Lint link', () => {
+ expect(wrapper.find('.js-ci-lint').attributes('href')).toEqual(paths.ciLintPath);
+ });
+
+ it('renders Clear Runner Cache button', () => {
+ expect(wrapper.find('.js-clear-cache').text()).toEqual('Clear Runner Caches');
+ });
+
+ it('renders tab empty state', () => {
+ expect(wrapper.find('.empty-state h4').text()).toEqual('There are currently no pipelines.');
+ });
+ });
+
+ describe('Without pipelines nor CI', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, {
+ pipelines: [],
+ count: {
+ all: 0,
+ pending: 0,
+ running: 0,
+ finished: 0,
+ },
+ });
+
+ createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths });
+
+ return waitForPromises();
+ });
+
+ it('renders empty state', () => {
+ expect(wrapper.find('.js-empty-state h4').text()).toEqual('Build with confidence');
+
+ expect(wrapper.find('.js-get-started-pipelines').attributes('href')).toEqual(
+ paths.helpPagePath,
+ );
+ });
+
+ it('does not render tabs nor buttons', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').exists()).toBeFalsy();
+ expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
+ expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
+ expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ });
+ });
+
+ describe('When API returns error', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(500, {});
+ createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths });
+
+ return waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ });
+
+ it('renders buttons', () => {
+ expect(wrapper.find('.js-run-pipeline').attributes('href')).toEqual(paths.newPipelinePath);
+
+ expect(wrapper.find('.js-ci-lint').attributes('href')).toEqual(paths.ciLintPath);
+ expect(wrapper.find('.js-clear-cache').text()).toEqual('Clear Runner Caches');
+ });
+
+ it('renders error state', () => {
+ expect(wrapper.find('.empty-state').text()).toContain(
+ 'There was an error fetching the pipelines.',
+ );
+ });
+ });
+ });
+
+ describe('Without permission', () => {
+ describe('With pipelines in main tab', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
+
+ createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions });
+
+ return waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ });
+
+ it('does not render buttons', () => {
+ expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
+ expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
+ expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ });
+
+ it('renders pipelines table', () => {
+ expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength(
+ pipelines.pipelines.length + 1,
+ );
+ });
+ });
+
+ describe('Without pipelines on main tab with CI', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, {
+ pipelines: [],
+ count: {
+ all: 0,
+ pending: 0,
+ running: 0,
+ finished: 0,
+ },
+ });
+
+ createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions });
+
+ return waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ });
+
+ it('does not render buttons', () => {
+ expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
+ expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
+ expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ });
+
+ it('renders tab empty state', () => {
+ expect(wrapper.find('.empty-state h4').text()).toEqual('There are currently no pipelines.');
+ });
+ });
+
+ describe('Without pipelines nor CI', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, {
+ pipelines: [],
+ count: {
+ all: 0,
+ pending: 0,
+ running: 0,
+ finished: 0,
+ },
+ });
+
+ createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions });
+
+ return waitForPromises();
+ });
+
+ it('renders empty state without button to set CI', () => {
+ expect(wrapper.find('.js-empty-state').text()).toEqual(
+ 'This project is not currently set up to run pipelines.',
+ );
+
+ expect(wrapper.find('.js-get-started-pipelines').exists()).toBeFalsy();
+ });
+
+ it('does not render tabs or buttons', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').exists()).toBeFalsy();
+ expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
+ expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
+ expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ });
+ });
+
+ describe('When API returns error', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(500, {});
+
+ createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...noPermissions });
+
+ return waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ });
+
+ it('does not renders buttons', () => {
+ expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
+ expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
+ expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ });
+
+ it('renders error state', () => {
+ expect(wrapper.find('.empty-state').text()).toContain(
+ 'There was an error fetching the pipelines.',
+ );
+ });
+ });
+ });
+
+ describe('successful request', () => {
+ describe('with pipelines', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
+
+ createComponent();
+ return waitForPromises();
+ });
+
+ it('should render table', () => {
+ expect(wrapper.find('.table-holder').exists()).toBe(true);
+ expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength(
+ pipelines.pipelines.length + 1,
+ );
+ });
+
+ it('should render navigation tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-pending').text()).toContain('Pending');
+
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+
+ expect(wrapper.find('.js-pipelines-tab-running').text()).toContain('Running');
+
+ expect(wrapper.find('.js-pipelines-tab-finished').text()).toContain('Finished');
+
+ expect(wrapper.find('.js-pipelines-tab-branches').text()).toContain('Branches');
+
+ expect(wrapper.find('.js-pipelines-tab-tags').text()).toContain('Tags');
+ });
+
+ it('should make an API request when using tabs', () => {
+ const updateContentMock = jest.fn(() => {});
+ createComponent(
+ { hasGitlabCi: true, canCreatePipeline: true, ...paths },
+ {
+ updateContent: updateContentMock,
+ },
+ );
+
+ return waitForPromises().then(() => {
+ wrapper.find('.js-pipelines-tab-finished').trigger('click');
+
+ expect(updateContentMock).toHaveBeenCalledWith({ scope: 'finished', page: '1' });
+ });
+ });
+
+ describe('with pagination', () => {
+ it('should make an API request when using pagination', () => {
+ const updateContentMock = jest.fn(() => {});
+ createComponent(
+ { hasGitlabCi: true, canCreatePipeline: true, ...paths },
+ {
+ updateContent: updateContentMock,
+ },
+ );
+
+ return waitForPromises()
+ .then(() => {
+ // Mock pagination
+ wrapper.vm.store.state.pageInfo = {
+ page: 1,
+ total: 10,
+ perPage: 2,
+ nextPage: 2,
+ totalPages: 5,
+ };
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ wrapper.find('.next-page-item').trigger('click');
+
+ expect(updateContentMock).toHaveBeenCalledWith({ scope: 'all', page: '2' });
+ });
+ });
+ });
+ });
+ });
+
+ describe('methods', () => {
+ beforeEach(() => {
+ jest.spyOn(window.history, 'pushState').mockImplementation(() => null);
+ });
+
+ describe('onChangeTab', () => {
+ it('should set page to 1', () => {
+ const updateContentMock = jest.fn(() => {});
+ createComponent(
+ { hasGitlabCi: true, canCreatePipeline: true, ...paths },
+ {
+ updateContent: updateContentMock,
+ },
+ );
+
+ wrapper.vm.onChangeTab('running');
+
+ expect(updateContentMock).toHaveBeenCalledWith({ scope: 'running', page: '1' });
+ });
+ });
+
+ describe('onChangePage', () => {
+ it('should update page and keep scope', () => {
+ const updateContentMock = jest.fn(() => {});
+ createComponent(
+ { hasGitlabCi: true, canCreatePipeline: true, ...paths },
+ {
+ updateContent: updateContentMock,
+ },
+ );
+
+ wrapper.vm.onChangePage(4);
+
+ expect(updateContentMock).toHaveBeenCalledWith({ scope: wrapper.vm.scope, page: '4' });
+ });
+ });
+ });
+
+ describe('computed properties', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('tabs', () => {
+ it('returns default tabs', () => {
+ expect(wrapper.vm.tabs).toEqual([
+ { name: 'All', scope: 'all', count: undefined, isActive: true },
+ { name: 'Pending', scope: 'pending', count: undefined, isActive: false },
+ { name: 'Running', scope: 'running', count: undefined, isActive: false },
+ { name: 'Finished', scope: 'finished', count: undefined, isActive: false },
+ { name: 'Branches', scope: 'branches', isActive: false },
+ { name: 'Tags', scope: 'tags', isActive: false },
+ ]);
+ });
+ });
+
+ describe('emptyTabMessage', () => {
+ it('returns message with scope', () => {
+ wrapper.vm.scope = 'pending';
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no pending pipelines.');
+ });
+ });
+
+ it('returns message without scope when scope is `all`', () => {
+ expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no pipelines.');
+ });
+ });
+
+ describe('stateToRender', () => {
+ it('returns loading state when the app is loading', () => {
+ expect(wrapper.vm.stateToRender).toEqual('loading');
+ });
+
+ it('returns error state when app has error', () => {
+ wrapper.vm.hasError = true;
+ wrapper.vm.isLoading = false;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.stateToRender).toEqual('error');
+ });
+ });
+
+ it('returns table list when app has pipelines', () => {
+ wrapper.vm.isLoading = false;
+ wrapper.vm.hasError = false;
+ wrapper.vm.state.pipelines = pipelines.pipelines;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.stateToRender).toEqual('tableList');
+ });
+ });
+
+ it('returns empty tab when app does not have pipelines but project has pipelines', () => {
+ wrapper.vm.state.count.all = 10;
+ wrapper.vm.isLoading = false;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.stateToRender).toEqual('emptyTab');
+ });
+ });
+
+ it('returns empty tab when project has CI', () => {
+ wrapper.vm.isLoading = false;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.stateToRender).toEqual('emptyTab');
+ });
+ });
+
+ it('returns empty state when project does not have pipelines nor CI', () => {
+ createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths });
+
+ wrapper.vm.isLoading = false;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.stateToRender).toEqual('emptyState');
+ });
+ });
+ });
+
+ describe('shouldRenderTabs', () => {
+ it('returns true when state is loading & has already made the first request', () => {
+ wrapper.vm.isLoading = true;
+ wrapper.vm.hasMadeRequest = true;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ });
+ });
+
+ it('returns true when state is tableList & has already made the first request', () => {
+ wrapper.vm.isLoading = false;
+ wrapper.vm.state.pipelines = pipelines.pipelines;
+ wrapper.vm.hasMadeRequest = true;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ });
+ });
+
+ it('returns true when state is error & has already made the first request', () => {
+ wrapper.vm.isLoading = false;
+ wrapper.vm.hasError = true;
+ wrapper.vm.hasMadeRequest = true;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ });
+ });
+
+ it('returns true when state is empty tab & has already made the first request', () => {
+ wrapper.vm.isLoading = false;
+ wrapper.vm.state.count.all = 10;
+ wrapper.vm.hasMadeRequest = true;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ });
+ });
+
+ it('returns false when has not made first request', () => {
+ wrapper.vm.hasMadeRequest = false;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderTabs).toEqual(false);
+ });
+ });
+
+ it('returns false when state is empty state', () => {
+ createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths });
+
+ wrapper.vm.isLoading = false;
+ wrapper.vm.hasMadeRequest = true;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderTabs).toEqual(false);
+ });
+ });
+ });
+
+ describe('shouldRenderButtons', () => {
+ it('returns true when it has paths & has made the first request', () => {
+ wrapper.vm.hasMadeRequest = true;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderButtons).toEqual(true);
+ });
+ });
+
+ it('returns false when it has not made the first request', () => {
+ wrapper.vm.hasMadeRequest = false;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderButtons).toEqual(false);
+ });
+ });
+ });
+ });
+
+ describe('updates results when a staged is clicked', () => {
+ beforeEach(() => {
+ const copyPipeline = { ...pipelineWithStages };
+ copyPipeline.id += 1;
+ mock
+ .onGet('twitter/flight/pipelines.json')
+ .reply(
+ 200,
+ {
+ pipelines: [pipelineWithStages],
+ count: {
+ all: 1,
+ finished: 1,
+ pending: 0,
+ running: 0,
+ },
+ },
+ {
+ 'POLL-INTERVAL': 100,
+ },
+ )
+ .onGet(pipelineWithStages.details.stages[0].dropdown_path)
+ .reply(200, stageReply);
+
+ createComponent();
+ });
+
+ describe('when a request is being made', () => {
+ it('stops polling, cancels the request, & restarts polling', () => {
+ const stopMock = jest.spyOn(wrapper.vm.poll, 'stop');
+ const restartMock = jest.spyOn(wrapper.vm.poll, 'restart');
+ const cancelMock = jest.spyOn(wrapper.vm.service.cancelationSource, 'cancel');
+ mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
+
+ return waitForPromises()
+ .then(() => {
+ wrapper.vm.isMakingRequest = true;
+ wrapper.find('.js-builds-dropdown-button').trigger('click');
+ })
+ .then(() => {
+ expect(cancelMock).toHaveBeenCalled();
+ expect(stopMock).toHaveBeenCalled();
+ expect(restartMock).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('when no request is being made', () => {
+ it('stops polling & restarts polling', () => {
+ const stopMock = jest.spyOn(wrapper.vm.poll, 'stop');
+ const restartMock = jest.spyOn(wrapper.vm.poll, 'restart');
+ mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
+
+ return waitForPromises()
+ .then(() => {
+ wrapper.find('.js-builds-dropdown-button').trigger('click');
+ expect(stopMock).toHaveBeenCalled();
+ })
+ .then(() => {
+ expect(restartMock).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe('Pipeline filters', () => {
+ let updateContentMock;
+
+ beforeEach(() => {
+ mock.onGet(paths.endpoint).reply(200, pipelines);
+ createComponent();
+
+ updateContentMock = jest.spyOn(wrapper.vm, 'updateContent');
+
+ return waitForPromises();
+ });
+
+ it('updates request data and query params on filter submit', () => {
+ const expectedQueryParams = { page: '1', scope: 'all', username: 'root', ref: 'master' };
+
+ findFilteredSearch().vm.$emit('submit', mockSearch);
+
+ expect(wrapper.vm.requestData).toEqual(expectedQueryParams);
+ expect(updateContentMock).toHaveBeenCalledWith(expectedQueryParams);
+ });
+
+ it('does not add query params if raw text search is used', () => {
+ const expectedQueryParams = { page: '1', scope: 'all' };
+
+ findFilteredSearch().vm.$emit('submit', ['rawText']);
+
+ expect(wrapper.vm.requestData).toEqual(expectedQueryParams);
+ expect(updateContentMock).toHaveBeenCalledWith(expectedQueryParams);
+ });
+
+ it('displays a warning message if raw text search is used', () => {
+ findFilteredSearch().vm.$emit('submit', ['rawText']);
+
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(RAW_TEXT_WARNING, 'warning');
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js
index c43210c5350..3d564c8758c 100644
--- a/spec/frontend/pipelines/pipelines_table_row_spec.js
+++ b/spec/frontend/pipelines/pipelines_table_row_spec.js
@@ -169,7 +169,7 @@ describe('Pipelines Table Row', () => {
};
beforeEach(() => {
- const withActions = Object.assign({}, pipeline);
+ const withActions = { ...pipeline };
withActions.details.scheduled_actions = [scheduledJobAction];
withActions.flags.cancelable = true;
withActions.flags.retryable = true;
diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js
new file mode 100644
index 00000000000..b0ab250dd16
--- /dev/null
+++ b/spec/frontend/pipelines/pipelines_table_spec.js
@@ -0,0 +1,66 @@
+import { mount } from '@vue/test-utils';
+import PipelinesTable from '~/pipelines/components/pipelines_table.vue';
+
+describe('Pipelines Table', () => {
+ let pipeline;
+ let wrapper;
+
+ const jsonFixtureName = 'pipelines/pipelines.json';
+
+ const defaultProps = {
+ pipelines: [],
+ autoDevopsHelpPath: 'foo',
+ viewType: 'root',
+ };
+
+ const createComponent = (props = defaultProps) => {
+ wrapper = mount(PipelinesTable, {
+ propsData: props,
+ });
+ };
+ const findRows = () => wrapper.findAll('.commit.gl-responsive-table-row');
+
+ preloadFixtures(jsonFixtureName);
+
+ beforeEach(() => {
+ const { pipelines } = getJSONFixture(jsonFixtureName);
+ pipeline = pipelines.find(p => p.user !== null && p.commit !== null);
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('table', () => {
+ it('should render a table', () => {
+ expect(wrapper.classes()).toContain('ci-table');
+ });
+
+ it('should render table head with correct columns', () => {
+ expect(wrapper.find('.table-section.js-pipeline-status').text()).toEqual('Status');
+
+ expect(wrapper.find('.table-section.js-pipeline-info').text()).toEqual('Pipeline');
+
+ expect(wrapper.find('.table-section.js-pipeline-commit').text()).toEqual('Commit');
+
+ expect(wrapper.find('.table-section.js-pipeline-stages').text()).toEqual('Stages');
+ });
+ });
+
+ describe('without data', () => {
+ it('should render an empty table', () => {
+ expect(findRows()).toHaveLength(0);
+ });
+ });
+
+ describe('with data', () => {
+ it('should render rows', () => {
+ createComponent({ pipelines: [pipeline], autoDevopsHelpPath: 'foo', viewType: 'root' });
+
+ expect(findRows()).toHaveLength(1);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/stage_spec.js b/spec/frontend/pipelines/stage_spec.js
new file mode 100644
index 00000000000..6aa041bcb7f
--- /dev/null
+++ b/spec/frontend/pipelines/stage_spec.js
@@ -0,0 +1,156 @@
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import StageComponent from '~/pipelines/components/stage.vue';
+import eventHub from '~/pipelines/event_hub';
+import { stageReply } from './mock_data';
+import waitForPromises from 'helpers/wait_for_promises';
+
+describe('Pipelines stage component', () => {
+ let wrapper;
+ let mock;
+
+ const defaultProps = {
+ stage: {
+ status: {
+ group: 'success',
+ icon: 'status_success',
+ title: 'success',
+ },
+ dropdown_path: 'path.json',
+ },
+ updateDropdown: false,
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = mount(StageComponent, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+
+ mock.restore();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should render a dropdown with the status icon', () => {
+ expect(wrapper.attributes('class')).toEqual('dropdown');
+ expect(wrapper.find('svg').exists()).toBe(true);
+ expect(wrapper.find('button').attributes('data-toggle')).toEqual('dropdown');
+ });
+ });
+
+ describe('with successful request', () => {
+ beforeEach(() => {
+ mock.onGet('path.json').reply(200, stageReply);
+ createComponent();
+ });
+
+ it('should render the received data and emit `clickedDropdown` event', () => {
+ jest.spyOn(eventHub, '$emit');
+ wrapper.find('button').trigger('click');
+
+ return waitForPromises().then(() => {
+ expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain(
+ stageReply.latest_statuses[0].name,
+ );
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown');
+ });
+ });
+ });
+
+ describe('when request fails', () => {
+ beforeEach(() => {
+ mock.onGet('path.json').reply(500);
+ createComponent();
+ });
+
+ it('should close the dropdown', () => {
+ wrapper.setMethods({
+ closeDropdown: jest.fn(),
+ isDropdownOpen: jest.fn().mockReturnValue(false),
+ });
+
+ wrapper.find('button').trigger('click');
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.closeDropdown).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('update endpoint correctly', () => {
+ beforeEach(() => {
+ const copyStage = { ...stageReply };
+ copyStage.latest_statuses[0].name = 'this is the updated content';
+ mock.onGet('bar.json').reply(200, copyStage);
+ createComponent({
+ stage: {
+ status: {
+ group: 'running',
+ icon: 'status_running',
+ title: 'running',
+ },
+ dropdown_path: 'bar.json',
+ },
+ });
+ });
+
+ it('should update the stage to request the new endpoint provided', () => {
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ wrapper.find('button').trigger('click');
+ return waitForPromises();
+ })
+ .then(() => {
+ expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain(
+ 'this is the updated content',
+ );
+ });
+ });
+ });
+
+ describe('pipelineActionRequestComplete', () => {
+ beforeEach(() => {
+ mock.onGet('path.json').reply(200, stageReply);
+
+ mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200);
+
+ createComponent({ type: 'PIPELINES_TABLE' });
+ });
+
+ describe('within pipeline table', () => {
+ it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', () => {
+ jest.spyOn(eventHub, '$emit');
+
+ wrapper.find('button').trigger('click');
+
+ return waitForPromises()
+ .then(() => {
+ wrapper.find('.js-ci-action').trigger('click');
+
+ return waitForPromises();
+ })
+ .then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable');
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/stores/pipeline_store_spec.js b/spec/frontend/pipelines/stores/pipeline_store_spec.js
new file mode 100644
index 00000000000..68d438109b3
--- /dev/null
+++ b/spec/frontend/pipelines/stores/pipeline_store_spec.js
@@ -0,0 +1,135 @@
+import PipelineStore from '~/pipelines/stores/pipeline_store';
+import LinkedPipelines from '../linked_pipelines_mock.json';
+
+describe('EE Pipeline store', () => {
+ let store;
+ let data;
+
+ beforeEach(() => {
+ store = new PipelineStore();
+ data = { ...LinkedPipelines };
+
+ store.storePipeline(data);
+ });
+
+ describe('storePipeline', () => {
+ describe('triggered_by', () => {
+ it('sets triggered_by as an array', () => {
+ expect(store.state.pipeline.triggered_by.length).toEqual(1);
+ });
+
+ it('adds isExpanding & isLoading keys set to false', () => {
+ expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
+ expect(store.state.pipeline.triggered_by[0].isLoading).toEqual(false);
+ });
+
+ it('parses nested triggered_by', () => {
+ expect(store.state.pipeline.triggered_by[0].triggered_by.length).toEqual(1);
+ expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false);
+ expect(store.state.pipeline.triggered_by[0].triggered_by[0].isLoading).toEqual(false);
+ });
+ });
+
+ describe('triggered', () => {
+ it('adds isExpanding & isLoading keys set to false for each triggered pipeline', () => {
+ store.state.pipeline.triggered.forEach(pipeline => {
+ expect(pipeline.isExpanded).toEqual(false);
+ expect(pipeline.isLoading).toEqual(false);
+ });
+ });
+
+ it('parses nested triggered pipelines', () => {
+ store.state.pipeline.triggered[1].triggered.forEach(pipeline => {
+ expect(pipeline.isExpanded).toEqual(false);
+ expect(pipeline.isLoading).toEqual(false);
+ });
+ });
+ });
+ });
+
+ describe('resetTriggeredByPipeline', () => {
+ it('closes the pipeline & nested ones', () => {
+ store.state.pipeline.triggered_by[0].isExpanded = true;
+ store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded = true;
+
+ store.resetTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
+
+ expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
+ expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false);
+ });
+ });
+
+ describe('openTriggeredByPipeline', () => {
+ it('opens the given pipeline', () => {
+ store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
+
+ expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(true);
+ });
+ });
+
+ describe('closeTriggeredByPipeline', () => {
+ it('closes the given pipeline', () => {
+ // open it first
+ store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
+
+ store.closeTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
+
+ expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
+ });
+ });
+
+ describe('resetTriggeredPipelines', () => {
+ it('closes the pipeline & nested ones', () => {
+ store.state.pipeline.triggered[0].isExpanded = true;
+ store.state.pipeline.triggered[0].triggered[0].isExpanded = true;
+
+ store.resetTriggeredPipelines(store.state.pipeline, store.state.pipeline.triggered[0]);
+
+ expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false);
+ expect(store.state.pipeline.triggered[0].triggered[0].isExpanded).toEqual(false);
+ });
+ });
+
+ describe('openTriggeredPipeline', () => {
+ it('opens the given pipeline', () => {
+ store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
+
+ expect(store.state.pipeline.triggered[0].isExpanded).toEqual(true);
+ });
+ });
+
+ describe('closeTriggeredPipeline', () => {
+ it('closes the given pipeline', () => {
+ // open it first
+ store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
+
+ store.closeTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
+
+ expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false);
+ });
+ });
+
+ describe('toggleLoading', () => {
+ it('toggles the isLoading property for the given pipeline', () => {
+ store.toggleLoading(store.state.pipeline.triggered[0]);
+
+ expect(store.state.pipeline.triggered[0].isLoading).toEqual(true);
+ });
+ });
+
+ describe('addExpandedPipelineToRequestData', () => {
+ it('pushes the given id to expandedPipelines array', () => {
+ store.addExpandedPipelineToRequestData('213231');
+
+ expect(store.state.expandedPipelines).toEqual(['213231']);
+ });
+ });
+
+ describe('removeExpandedPipelineToRequestData', () => {
+ it('pushes the given id to expandedPipelines array', () => {
+ store.removeExpandedPipelineToRequestData('213231');
+
+ expect(store.state.expandedPipelines).toEqual([]);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
index 9eaa563025d..a0eb93c4e6b 100644
--- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
+++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
@@ -20,7 +20,7 @@ describe('Mutations TestReports Store', () => {
describe('set endpoint', () => {
it('should set endpoint', () => {
- const expectedState = Object.assign({}, mockState, { endpoint: 'foo' });
+ const expectedState = { ...mockState, endpoint: 'foo' };
mutations[types.SET_ENDPOINT](mockState, 'foo');
expect(mockState.endpoint).toEqual(expectedState.endpoint);
@@ -47,14 +47,14 @@ describe('Mutations TestReports Store', () => {
describe('toggle loading', () => {
it('should set to true', () => {
- const expectedState = Object.assign({}, mockState, { isLoading: true });
+ const expectedState = { ...mockState, isLoading: true };
mutations[types.TOGGLE_LOADING](mockState);
expect(mockState.isLoading).toEqual(expectedState.isLoading);
});
it('should toggle back to false', () => {
- const expectedState = Object.assign({}, mockState, { isLoading: false });
+ const expectedState = { ...mockState, isLoading: false };
mockState.isLoading = true;
mutations[types.TOGGLE_LOADING](mockState);
diff --git a/spec/frontend/pipelines/test_reports/test_summary_spec.js b/spec/frontend/pipelines/test_reports/test_summary_spec.js
index 160d93d2e6b..8f041e46472 100644
--- a/spec/frontend/pipelines/test_reports/test_summary_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_summary_spec.js
@@ -82,17 +82,19 @@ describe('Test reports summary', () => {
describe('success percentage calculation', () => {
it.each`
- name | successCount | totalCount | result
- ${'displays 0 when there are no tests'} | ${0} | ${0} | ${'0'}
- ${'displays whole number when possible'} | ${10} | ${50} | ${'20'}
- ${'rounds to 0.01'} | ${1} | ${16604} | ${'0.01'}
- ${'correctly rounds to 50'} | ${8302} | ${16604} | ${'50'}
- ${'rounds down for large close numbers'} | ${16603} | ${16604} | ${'99.99'}
- ${'correctly displays 100'} | ${16604} | ${16604} | ${'100'}
- `('$name', ({ successCount, totalCount, result }) => {
+ name | successCount | totalCount | skippedCount | result
+ ${'displays 0 when there are no tests'} | ${0} | ${0} | ${0} | ${'0'}
+ ${'displays whole number when possible'} | ${10} | ${50} | ${0} | ${'20'}
+ ${'excludes skipped tests from total'} | ${10} | ${50} | ${5} | ${'22.22'}
+ ${'rounds to 0.01'} | ${1} | ${16604} | ${0} | ${'0.01'}
+ ${'correctly rounds to 50'} | ${8302} | ${16604} | ${0} | ${'50'}
+ ${'rounds down for large close numbers'} | ${16603} | ${16604} | ${0} | ${'99.99'}
+ ${'correctly displays 100'} | ${16604} | ${16604} | ${0} | ${'100'}
+ `('$name', ({ successCount, totalCount, skippedCount, result }) => {
createComponent({
report: {
success_count: successCount,
+ skipped_count: skippedCount,
total_count: totalCount,
},
});
diff --git a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js
index 9146f301f66..b585536ae09 100644
--- a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js
@@ -37,11 +37,47 @@ describe('Test reports summary table', () => {
describe('when test reports are supplied', () => {
beforeEach(() => createComponent());
+ const findErrorIcon = () => wrapper.find({ ref: 'suiteErrorIcon' });
it('renders the correct number of rows', () => {
expect(noSuitesToShow().exists()).toBe(false);
expect(allSuitesRows().length).toBe(testReports.test_suites.length);
});
+
+ describe('when there is a suite error', () => {
+ beforeEach(() => {
+ createComponent({
+ test_suites: [
+ {
+ ...testReports.test_suites[0],
+ suite_error: 'Suite Error',
+ },
+ ],
+ });
+ });
+
+ it('renders error icon', () => {
+ expect(findErrorIcon().exists()).toBe(true);
+ expect(findErrorIcon().attributes('title')).toEqual('Suite Error');
+ });
+ });
+
+ describe('when there is not a suite error', () => {
+ beforeEach(() => {
+ createComponent({
+ test_suites: [
+ {
+ ...testReports.test_suites[0],
+ suite_error: null,
+ },
+ ],
+ });
+ });
+
+ it('does not render error icon', () => {
+ expect(findErrorIcon().exists()).toBe(false);
+ });
+ });
});
describe('when there are no test suites', () => {
diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js
new file mode 100644
index 00000000000..1bd16182d47
--- /dev/null
+++ b/spec/frontend/pipelines/time_ago_spec.js
@@ -0,0 +1,67 @@
+import { shallowMount } from '@vue/test-utils';
+import TimeAgo from '~/pipelines/components/time_ago.vue';
+
+describe('Timeago component', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(TimeAgo, {
+ propsData: {
+ ...props,
+ },
+ data() {
+ return {
+ iconTimerSvg: `<svg></svg>`,
+ };
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ 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);
+ });
+ });
+
+ describe('without duration', () => {
+ beforeEach(() => {
+ createComponent({ duration: 0, finishedTime: '' });
+ });
+
+ it('should not render duration and timer svg', () => {
+ expect(wrapper.find('.duration').exists()).toBe(false);
+ });
+ });
+
+ describe('with finishedTime', () => {
+ beforeEach(() => {
+ createComponent({ duration: 0, finishedTime: '2017-04-26T12:40:23.277Z' });
+ });
+
+ 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);
+ });
+ });
+
+ describe('without finishedTime', () => {
+ beforeEach(() => {
+ createComponent({ duration: 0, finishedTime: '' });
+ });
+
+ it('should not render time and calendar icon', () => {
+ expect(wrapper.find('.finished-at').exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js
new file mode 100644
index 00000000000..a6753600792
--- /dev/null
+++ b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js
@@ -0,0 +1,89 @@
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import PipelineBranchNameToken from '~/pipelines/components/tokens/pipeline_branch_name_token.vue';
+import { branches } from '../mock_data';
+
+describe('Pipeline Branch Name Token', () => {
+ let wrapper;
+
+ const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
+ const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ const stubs = {
+ GlFilteredSearchToken: {
+ template: `<div><slot name="suggestions"></slot></div>`,
+ },
+ };
+
+ const defaultProps = {
+ config: {
+ type: 'ref',
+ icon: 'branch',
+ title: 'Branch name',
+ dataType: 'ref',
+ unique: true,
+ branches,
+ projectId: '21',
+ },
+ value: {
+ data: '',
+ },
+ };
+
+ const createComponent = (options, data) => {
+ wrapper = shallowMount(PipelineBranchNameToken, {
+ propsData: {
+ ...defaultProps,
+ },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('passes config correctly', () => {
+ expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
+ });
+
+ describe('displays loading icon correctly', () => {
+ it('shows loading icon', () => {
+ createComponent({ stubs }, { loading: true });
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not show loading icon', () => {
+ createComponent({ stubs }, { loading: false });
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('shows branches correctly', () => {
+ it('renders all trigger authors', () => {
+ createComponent({ stubs }, { branches, loading: false });
+
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(branches.length);
+ });
+
+ it('renders only the branch searched for', () => {
+ const mockBranches = ['master'];
+ createComponent({ stubs }, { branches: mockBranches, loading: false });
+
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(mockBranches.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
new file mode 100644
index 00000000000..00a9ff04e75
--- /dev/null
+++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js
@@ -0,0 +1,98 @@
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import PipelineTriggerAuthorToken from '~/pipelines/components/tokens/pipeline_trigger_author_token.vue';
+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: {
+ template: `<div><slot name="suggestions"></slot></div>`,
+ },
+ };
+
+ const defaultProps = {
+ config: {
+ type: 'username',
+ icon: 'user',
+ title: 'Trigger author',
+ dataType: 'username',
+ unique: true,
+ triggerAuthors: users,
+ },
+ value: {
+ data: '',
+ },
+ };
+
+ const createComponent = (options, data) => {
+ wrapper = shallowMount(PipelineTriggerAuthorToken, {
+ propsData: {
+ ...defaultProps,
+ },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('passes config correctly', () => {
+ expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
+ });
+
+ describe('displays loading icon correctly', () => {
+ it('shows loading icon', () => {
+ createComponent({ stubs }, { loading: true });
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not show loading icon', () => {
+ createComponent({ stubs }, { loading: false });
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('shows trigger authors correctly', () => {
+ beforeEach(() => {});
+
+ it('renders all trigger authors', () => {
+ createComponent({ stubs }, { 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,
+ },
+ );
+
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(2);
+ });
+ });
+});
diff --git a/spec/javascripts/pipelines_spec.js b/spec/frontend/pipelines_spec.js
index 6d4d634c575..6d4d634c575 100644
--- a/spec/javascripts/pipelines_spec.js
+++ b/spec/frontend/pipelines_spec.js
diff --git a/spec/frontend/prometheus_metrics/custom_metrics_spec.js b/spec/frontend/prometheus_metrics/custom_metrics_spec.js
index 97b8f7bd913..1244d7342ad 100644
--- a/spec/frontend/prometheus_metrics/custom_metrics_spec.js
+++ b/spec/frontend/prometheus_metrics/custom_metrics_spec.js
@@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import CustomMetrics from '~/prometheus_metrics/custom_metrics';
import axios from '~/lib/utils/axios_utils';
import PANEL_STATE from '~/prometheus_metrics/constants';
-import metrics from './mock_data';
+import { metrics1 as metrics } from './mock_data';
describe('PrometheusMetrics', () => {
const FIXTURE = 'services/prometheus/prometheus_service.html';
diff --git a/spec/frontend/prometheus_metrics/mock_data.js b/spec/frontend/prometheus_metrics/mock_data.js
index d5532537302..375447ac3be 100644
--- a/spec/frontend/prometheus_metrics/mock_data.js
+++ b/spec/frontend/prometheus_metrics/mock_data.js
@@ -1,4 +1,4 @@
-const metrics = [
+export const metrics1 = [
{
edit_path: '/root/prometheus-test/prometheus/metrics/3/edit',
id: 3,
@@ -19,4 +19,44 @@ const metrics = [
},
];
-export default metrics;
+export const metrics2 = [
+ {
+ group: 'Kubernetes',
+ priority: 1,
+ active_metrics: 4,
+ metrics_missing_requirements: 0,
+ },
+ {
+ group: 'HAProxy',
+ priority: 2,
+ active_metrics: 3,
+ metrics_missing_requirements: 0,
+ },
+ {
+ group: 'Apache',
+ priority: 3,
+ active_metrics: 5,
+ metrics_missing_requirements: 0,
+ },
+];
+
+export const missingVarMetrics = [
+ {
+ group: 'Kubernetes',
+ priority: 1,
+ active_metrics: 4,
+ metrics_missing_requirements: 0,
+ },
+ {
+ group: 'HAProxy',
+ priority: 2,
+ active_metrics: 3,
+ metrics_missing_requirements: 1,
+ },
+ {
+ group: 'Apache',
+ priority: 3,
+ active_metrics: 5,
+ metrics_missing_requirements: 3,
+ },
+];
diff --git a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
new file mode 100644
index 00000000000..437a2116f5c
--- /dev/null
+++ b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
@@ -0,0 +1,178 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
+import PANEL_STATE from '~/prometheus_metrics/constants';
+import { metrics2 as metrics, missingVarMetrics } from './mock_data';
+
+describe('PrometheusMetrics', () => {
+ const FIXTURE = 'services/prometheus/prometheus_service.html';
+ preloadFixtures(FIXTURE);
+
+ beforeEach(() => {
+ loadFixtures(FIXTURE);
+ });
+
+ describe('constructor', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should initialize wrapper element refs on class object', () => {
+ expect(prometheusMetrics.$wrapper).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsPanel).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsCount).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsLoading).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsEmpty).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsList).toBeDefined();
+ expect(prometheusMetrics.$missingEnvVarPanel).toBeDefined();
+ expect(prometheusMetrics.$panelToggle).toBeDefined();
+ expect(prometheusMetrics.$missingEnvVarMetricCount).toBeDefined();
+ expect(prometheusMetrics.$missingEnvVarMetricsList).toBeDefined();
+ });
+
+ it('should initialize metadata on class object', () => {
+ expect(prometheusMetrics.backOffRequestCounter).toEqual(0);
+ expect(prometheusMetrics.activeMetricsEndpoint).toContain('/test');
+ });
+ });
+
+ describe('showMonitoringMetricsPanelState', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should show loading state when called with `loading`', () => {
+ prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.LOADING);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeTruthy();
+ });
+
+ it('should show metrics list when called with `list`', () => {
+ prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.LIST);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy();
+ });
+
+ it('should show empty state when called with `empty`', () => {
+ prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeFalsy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeTruthy();
+ });
+ });
+
+ describe('populateActiveMetrics', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should show monitored metrics list', () => {
+ prometheusMetrics.populateActiveMetrics(metrics);
+
+ const $metricsListLi = prometheusMetrics.$monitoredMetricsList.find('li');
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy();
+
+ expect(prometheusMetrics.$monitoredMetricsCount.text()).toEqual(
+ '3 exporters with 12 metrics were found',
+ );
+
+ expect($metricsListLi.length).toEqual(metrics.length);
+ expect(
+ $metricsListLi
+ .first()
+ .find('.badge')
+ .text(),
+ ).toEqual(`${metrics[0].active_metrics}`);
+ });
+
+ it('should show missing environment variables list', () => {
+ prometheusMetrics.populateActiveMetrics(missingVarMetrics);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$missingEnvVarPanel.hasClass('hidden')).toBeFalsy();
+
+ expect(prometheusMetrics.$missingEnvVarMetricCount.text()).toEqual('2');
+ expect(prometheusMetrics.$missingEnvVarPanel.find('li').length).toEqual(2);
+ expect(prometheusMetrics.$missingEnvVarPanel.find('.flash-container')).toBeDefined();
+ });
+ });
+
+ describe('loadActiveMetrics', () => {
+ let prometheusMetrics;
+ let mock;
+
+ function mockSuccess() {
+ mock.onGet(prometheusMetrics.activeMetricsEndpoint).reply(200, {
+ data: metrics,
+ success: true,
+ });
+ }
+
+ function mockError() {
+ mock.onGet(prometheusMetrics.activeMetricsEndpoint).networkError();
+ }
+
+ beforeEach(() => {
+ jest.spyOn(axios, 'get');
+
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should show loader animation while response is being loaded and hide it when request is complete', done => {
+ mockSuccess();
+
+ prometheusMetrics.loadActiveMetrics();
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy();
+ expect(axios.get).toHaveBeenCalledWith(prometheusMetrics.activeMetricsEndpoint);
+
+ setImmediate(() => {
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should show empty state if response failed to load', done => {
+ mockError();
+
+ prometheusMetrics.loadActiveMetrics();
+
+ setImmediate(() => {
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeFalsy();
+ done();
+ });
+ });
+
+ it('should populate metrics list once response is loaded', done => {
+ jest.spyOn(prometheusMetrics, 'populateActiveMetrics').mockImplementation();
+ mockSuccess();
+
+ prometheusMetrics.loadActiveMetrics();
+
+ setImmediate(() => {
+ expect(prometheusMetrics.populateActiveMetrics).toHaveBeenCalledWith(metrics);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/registry/explorer/components/image_list_spec.js b/spec/frontend/registry/explorer/components/image_list_spec.js
new file mode 100644
index 00000000000..12f0fbe0c87
--- /dev/null
+++ b/spec/frontend/registry/explorer/components/image_list_spec.js
@@ -0,0 +1,74 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlPagination } from '@gitlab/ui';
+import Component from '~/registry/explorer/components/image_list.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { RouterLink } from '../stubs';
+import { imagesListResponse, imagePagination } from '../mock_data';
+
+describe('Image List', () => {
+ let wrapper;
+
+ const firstElement = imagesListResponse.data[0];
+
+ const findDeleteBtn = () => wrapper.find('[data-testid="deleteImageButton"]');
+ const findRowItems = () => wrapper.findAll('[data-testid="rowItem"]');
+ const findDetailsLink = () => wrapper.find('[data-testid="detailsLink"]');
+ const findClipboardButton = () => wrapper.find(ClipboardButton);
+ const findPagination = () => wrapper.find(GlPagination);
+
+ const mountComponent = () => {
+ wrapper = shallowMount(Component, {
+ stubs: {
+ RouterLink,
+ },
+ propsData: {
+ images: imagesListResponse.data,
+ pagination: imagePagination,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('contains one list element for each image', () => {
+ expect(findRowItems().length).toBe(imagesListResponse.data.length);
+ });
+
+ it('contains a link to the details page', () => {
+ const link = findDetailsLink();
+ expect(link.html()).toContain(firstElement.path);
+ expect(link.props('to').name).toBe('details');
+ });
+
+ it('contains a clipboard button', () => {
+ const button = findClipboardButton();
+ expect(button.exists()).toBe(true);
+ expect(button.props('text')).toBe(firstElement.location);
+ expect(button.props('title')).toBe(firstElement.location);
+ });
+
+ it('should be possible to delete a repo', () => {
+ const deleteBtn = findDeleteBtn();
+ expect(deleteBtn.exists()).toBe(true);
+ });
+
+ describe('pagination', () => {
+ it('exists', () => {
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ it('is wired to the correct pagination props', () => {
+ const pagination = findPagination();
+ expect(pagination.props('perPage')).toBe(imagePagination.perPage);
+ expect(pagination.props('totalItems')).toBe(imagePagination.total);
+ expect(pagination.props('value')).toBe(imagePagination.page);
+ });
+
+ it('emits a pageChange event when the page change', () => {
+ wrapper.setData({ currentPage: 2 });
+ expect(wrapper.emitted('pageChange')).toEqual([[2]]);
+ });
+ });
+});
diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js
index 2d8cd4e42bc..f6beccda9b1 100644
--- a/spec/frontend/registry/explorer/mock_data.js
+++ b/spec/frontend/registry/explorer/mock_data.js
@@ -87,3 +87,11 @@ export const tagsListResponse = {
],
headers,
};
+
+export const imagePagination = {
+ perPage: 10,
+ page: 1,
+ total: 14,
+ totalPages: 2,
+ nextPage: 2,
+};
diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js
index 15aa5008413..93098403a28 100644
--- a/spec/frontend/registry/explorer/pages/details_spec.js
+++ b/spec/frontend/registry/explorer/pages/details_spec.js
@@ -1,15 +1,21 @@
import { mount } from '@vue/test-utils';
-import { GlTable, GlPagination, GlSkeletonLoader } from '@gitlab/ui';
+import { GlTable, GlPagination, GlSkeletonLoader, GlAlert, GlLink } from '@gitlab/ui';
import Tracking from '~/tracking';
import stubChildren from 'helpers/stub_children';
import component from '~/registry/explorer/pages/details.vue';
-import store from '~/registry/explorer/stores/';
-import { SET_MAIN_LOADING } from '~/registry/explorer/stores/mutation_types/';
+import { createStore } from '~/registry/explorer/stores/';
+import {
+ SET_MAIN_LOADING,
+ SET_INITIAL_STATE,
+ SET_TAGS_LIST_SUCCESS,
+ SET_TAGS_PAGINATION,
+} from '~/registry/explorer/stores/mutation_types/';
import {
DELETE_TAG_SUCCESS_MESSAGE,
DELETE_TAG_ERROR_MESSAGE,
DELETE_TAGS_SUCCESS_MESSAGE,
DELETE_TAGS_ERROR_MESSAGE,
+ ADMIN_GARBAGE_COLLECTION_TIP,
} from '~/registry/explorer/constants';
import { tagsListResponse } from '../mock_data';
import { GlModal } from '../stubs';
@@ -18,6 +24,7 @@ import { $toast } from '../../shared/mocks';
describe('Details Page', () => {
let wrapper;
let dispatchSpy;
+ let store;
const findDeleteModal = () => wrapper.find(GlModal);
const findPagination = () => wrapper.find(GlPagination);
@@ -30,6 +37,8 @@ describe('Details Page', () => {
const findAllCheckboxes = () => wrapper.findAll('.js-row-checkbox');
const findCheckedCheckboxes = () => findAllCheckboxes().filter(c => c.attributes('checked'));
const findFirsTagColumn = () => wrapper.find('.js-tag-column');
+ const findFirstTagNameText = () => wrapper.find('[data-testid="rowNameText"]');
+ const findAlert = () => wrapper.find(GlAlert);
const routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' }));
@@ -55,13 +64,17 @@ describe('Details Page', () => {
};
beforeEach(() => {
+ store = createStore();
dispatchSpy = jest.spyOn(store, 'dispatch');
- store.dispatch('receiveTagsListSuccess', tagsListResponse);
+ dispatchSpy.mockResolvedValue();
+ store.commit(SET_TAGS_LIST_SUCCESS, tagsListResponse.data);
+ store.commit(SET_TAGS_PAGINATION, tagsListResponse.headers);
jest.spyOn(Tracking, 'event');
});
afterEach(() => {
wrapper.destroy();
+ wrapper = null;
});
describe('when isLoading is true', () => {
@@ -130,10 +143,6 @@ describe('Details Page', () => {
});
describe('row checkbox', () => {
- beforeEach(() => {
- mountComponent();
- });
-
it('if selected adds item to selectedItems', () => {
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
@@ -240,15 +249,24 @@ describe('Details Page', () => {
});
});
- describe('tag cell', () => {
+ describe('name cell', () => {
+ it('tag column has a tooltip with the tag name', () => {
+ mountComponent();
+ expect(findFirstTagNameText().attributes('title')).toBe(tagsListResponse.data[0].name);
+ });
+
describe('on desktop viewport', () => {
beforeEach(() => {
mountComponent();
});
- it('has class w-25', () => {
+ it('table header has class w-25', () => {
expect(findFirsTagColumn().classes()).toContain('w-25');
});
+
+ it('tag column has the mw-m class', () => {
+ expect(findFirstRowItem('rowName').classes()).toContain('mw-m');
+ });
});
describe('on mobile viewport', () => {
@@ -260,9 +278,28 @@ describe('Details Page', () => {
});
});
- it('does not has class w-25', () => {
+ it('table header does not have class w-25', () => {
expect(findFirsTagColumn().classes()).not.toContain('w-25');
});
+
+ it('tag column has the gl-justify-content-end class', () => {
+ expect(findFirstRowItem('rowName').classes()).toContain('gl-justify-content-end');
+ });
+ });
+ });
+
+ describe('last updated cell', () => {
+ let timeCell;
+
+ beforeEach(() => {
+ timeCell = findFirstRowItem('rowTime');
+ });
+
+ it('displays the time in string format', () => {
+ expect(timeCell.text()).toBe('2 years ago');
+ });
+ it('has a tooltip timestamp', () => {
+ expect(timeCell.attributes('title')).toBe('Sep 19, 2017 1:45pm GMT+0000');
});
});
});
@@ -328,25 +365,9 @@ describe('Details Page', () => {
});
// itemsToBeDeleted is not represented in the DOM, is used as parking variable between selected and deleted items
expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
+ expect(wrapper.vm.selectedItems).toEqual([]);
expect(findCheckedCheckboxes()).toHaveLength(0);
});
-
- it('show success toast on successful delete', () => {
- return wrapper.vm.handleSingleDelete(0).then(() => {
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_TAG_SUCCESS_MESSAGE, {
- type: 'success',
- });
- });
- });
-
- it('show error toast on erred delete', () => {
- dispatchSpy.mockRejectedValue();
- return wrapper.vm.handleSingleDelete(0).then(() => {
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_TAG_ERROR_MESSAGE, {
- type: 'error',
- });
- });
- });
});
describe('when multiple elements are selected', () => {
@@ -365,23 +386,6 @@ describe('Details Page', () => {
expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
expect(findCheckedCheckboxes()).toHaveLength(0);
});
-
- it('show success toast on successful delete', () => {
- return wrapper.vm.handleMultipleDelete(0).then(() => {
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_TAGS_SUCCESS_MESSAGE, {
- type: 'success',
- });
- });
- });
-
- it('show error toast on erred delete', () => {
- dispatchSpy.mockRejectedValue();
- return wrapper.vm.handleMultipleDelete(0).then(() => {
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_TAGS_ERROR_MESSAGE, {
- type: 'error',
- });
- });
- });
});
});
@@ -395,4 +399,108 @@ describe('Details Page', () => {
});
});
});
+
+ describe('Delete alert', () => {
+ const config = {
+ garbageCollectionHelpPagePath: 'foo',
+ };
+
+ describe('when the user is an admin', () => {
+ beforeEach(() => {
+ store.commit(SET_INITIAL_STATE, { ...config, isAdmin: true });
+ });
+
+ afterEach(() => {
+ store.commit(SET_INITIAL_STATE, config);
+ });
+
+ describe.each`
+ deleteType | successTitle | errorTitle
+ ${'handleSingleDelete'} | ${DELETE_TAG_SUCCESS_MESSAGE} | ${DELETE_TAG_ERROR_MESSAGE}
+ ${'handleMultipleDelete'} | ${DELETE_TAGS_SUCCESS_MESSAGE} | ${DELETE_TAGS_ERROR_MESSAGE}
+ `('behaves correctly on $deleteType', ({ deleteType, successTitle, errorTitle }) => {
+ describe('when delete is successful', () => {
+ beforeEach(() => {
+ dispatchSpy.mockResolvedValue();
+ mountComponent();
+ return wrapper.vm[deleteType]('foo');
+ });
+
+ it('alert exists', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('alert body contains admin tip', () => {
+ expect(
+ findAlert()
+ .text()
+ .replace(/\s\s+/gm, ' '),
+ ).toBe(ADMIN_GARBAGE_COLLECTION_TIP.replace(/%{\w+}/gm, ''));
+ });
+
+ it('alert body contains link', () => {
+ const alertLink = findAlert().find(GlLink);
+ expect(alertLink.exists()).toBe(true);
+ expect(alertLink.attributes('href')).toBe(config.garbageCollectionHelpPagePath);
+ });
+
+ it('alert title is appropriate', () => {
+ expect(findAlert().attributes('title')).toBe(successTitle);
+ });
+ });
+
+ describe('when delete is not successful', () => {
+ beforeEach(() => {
+ mountComponent();
+ dispatchSpy.mockRejectedValue();
+ return wrapper.vm[deleteType]('foo');
+ });
+
+ it('alert exist and text is appropriate', () => {
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(errorTitle);
+ });
+ });
+ });
+ });
+
+ describe.each`
+ deleteType | successTitle | errorTitle
+ ${'handleSingleDelete'} | ${DELETE_TAG_SUCCESS_MESSAGE} | ${DELETE_TAG_ERROR_MESSAGE}
+ ${'handleMultipleDelete'} | ${DELETE_TAGS_SUCCESS_MESSAGE} | ${DELETE_TAGS_ERROR_MESSAGE}
+ `(
+ 'when the user is not an admin alert behaves correctly on $deleteType',
+ ({ deleteType, successTitle, errorTitle }) => {
+ beforeEach(() => {
+ store.commit('SET_INITIAL_STATE', { ...config });
+ });
+
+ describe('when delete is successful', () => {
+ beforeEach(() => {
+ dispatchSpy.mockResolvedValue();
+ mountComponent();
+ return wrapper.vm[deleteType]('foo');
+ });
+
+ it('alert exist and text is appropriate', () => {
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(successTitle);
+ });
+ });
+
+ describe('when delete is not successful', () => {
+ beforeEach(() => {
+ mountComponent();
+ dispatchSpy.mockRejectedValue();
+ return wrapper.vm[deleteType]('foo');
+ });
+
+ it('alert exist and text is appropriate', () => {
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(errorTitle);
+ });
+ });
+ },
+ );
+ });
});
diff --git a/spec/frontend/registry/explorer/pages/index_spec.js b/spec/frontend/registry/explorer/pages/index_spec.js
index f52e7d67866..b558727ed5e 100644
--- a/spec/frontend/registry/explorer/pages/index_spec.js
+++ b/spec/frontend/registry/explorer/pages/index_spec.js
@@ -1,62 +1,26 @@
import { shallowMount } from '@vue/test-utils';
-import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
import component from '~/registry/explorer/pages/index.vue';
import store from '~/registry/explorer/stores/';
describe('List Page', () => {
let wrapper;
- let dispatchSpy;
const findRouterView = () => wrapper.find({ ref: 'router-view' });
- const findAlert = () => wrapper.find(GlAlert);
- const findLink = () => wrapper.find(GlLink);
const mountComponent = () => {
wrapper = shallowMount(component, {
store,
stubs: {
RouterView: true,
- GlSprintf,
},
});
};
beforeEach(() => {
- dispatchSpy = jest.spyOn(store, 'dispatch');
mountComponent();
});
it('has a router view', () => {
expect(findRouterView().exists()).toBe(true);
});
-
- describe('garbageCollectionTip alert', () => {
- beforeEach(() => {
- store.dispatch('setInitialState', { isAdmin: true, garbageCollectionHelpPagePath: 'foo' });
- store.dispatch('setShowGarbageCollectionTip', true);
- });
-
- afterEach(() => {
- store.dispatch('setInitialState', {});
- store.dispatch('setShowGarbageCollectionTip', false);
- });
-
- it('is visible when the user is an admin and the user performed a delete action', () => {
- expect(findAlert().exists()).toBe(true);
- });
-
- it('on dismiss disappears ', () => {
- findAlert().vm.$emit('dismiss');
- expect(dispatchSpy).toHaveBeenCalledWith('setShowGarbageCollectionTip', false);
- return wrapper.vm.$nextTick().then(() => {
- expect(findAlert().exists()).toBe(false);
- });
- });
-
- it('contains a link to the docs', () => {
- const link = findLink();
- expect(link.exists()).toBe(true);
- expect(link.attributes('href')).toBe(store.state.config.garbageCollectionHelpPagePath);
- });
- });
});
diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js
index f69b849521d..97742b9e9b3 100644
--- a/spec/frontend/registry/explorer/pages/list_spec.js
+++ b/spec/frontend/registry/explorer/pages/list_spec.js
@@ -1,47 +1,53 @@
-import VueRouter from 'vue-router';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlPagination, GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitlab/ui';
import Tracking from '~/tracking';
+import waitForPromises from 'helpers/wait_for_promises';
import component from '~/registry/explorer/pages/list.vue';
import QuickstartDropdown from '~/registry/explorer/components/quickstart_dropdown.vue';
import GroupEmptyState from '~/registry/explorer/components/group_empty_state.vue';
import ProjectEmptyState from '~/registry/explorer/components/project_empty_state.vue';
import ProjectPolicyAlert from '~/registry/explorer/components/project_policy_alert.vue';
-import store from '~/registry/explorer/stores/';
-import { SET_MAIN_LOADING } from '~/registry/explorer/stores/mutation_types/';
+import ImageList from '~/registry/explorer/components/image_list.vue';
+import { createStore } from '~/registry/explorer/stores/';
+import {
+ SET_MAIN_LOADING,
+ SET_IMAGES_LIST_SUCCESS,
+ SET_PAGINATION,
+ SET_INITIAL_STATE,
+} from '~/registry/explorer/stores/mutation_types/';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
+ IMAGE_REPOSITORY_LIST_LABEL,
+ SEARCH_PLACEHOLDER_TEXT,
} from '~/registry/explorer/constants';
import { imagesListResponse } from '../mock_data';
import { GlModal, GlEmptyState } from '../stubs';
import { $toast } from '../../shared/mocks';
-const localVue = createLocalVue();
-localVue.use(VueRouter);
-
describe('List Page', () => {
let wrapper;
let dispatchSpy;
+ let store;
- const findDeleteBtn = () => wrapper.find({ ref: 'deleteImageButton' });
const findDeleteModal = () => wrapper.find(GlModal);
const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
const findImagesList = () => wrapper.find({ ref: 'imagesList' });
- const findRowItems = () => wrapper.findAll({ ref: 'rowItem' });
+
const findEmptyState = () => wrapper.find(GlEmptyState);
- const findDetailsLink = () => wrapper.find({ ref: 'detailsLink' });
- const findClipboardButton = () => wrapper.find({ ref: 'clipboardButton' });
- const findPagination = () => wrapper.find(GlPagination);
+
const findQuickStartDropdown = () => wrapper.find(QuickstartDropdown);
const findProjectEmptyState = () => wrapper.find(ProjectEmptyState);
const findGroupEmptyState = () => wrapper.find(GroupEmptyState);
const findProjectPolicyAlert = () => wrapper.find(ProjectPolicyAlert);
const findDeleteAlert = () => wrapper.find(GlAlert);
+ const findImageList = () => wrapper.find(ImageList);
+ const findListHeader = () => wrapper.find('[data-testid="listHeader"]');
+ const findSearchBox = () => wrapper.find(GlSearchBoxByClick);
+ const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]');
- beforeEach(() => {
+ const mountComponent = ({ mocks } = {}) => {
wrapper = shallowMount(component, {
- localVue,
store,
stubs: {
GlModal,
@@ -50,10 +56,20 @@ describe('List Page', () => {
},
mocks: {
$toast,
+ $route: {
+ name: 'foo',
+ },
+ ...mocks,
},
});
+ };
+
+ beforeEach(() => {
+ store = createStore();
dispatchSpy = jest.spyOn(store, 'dispatch');
- store.dispatch('receiveImagesListSuccess', imagesListResponse);
+ dispatchSpy.mockResolvedValue();
+ store.commit(SET_IMAGES_LIST_SUCCESS, imagesListResponse.data);
+ store.commit(SET_PAGINATION, imagesListResponse.headers);
});
afterEach(() => {
@@ -61,17 +77,38 @@ describe('List Page', () => {
});
describe('Expiration policy notification', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
it('shows up on project page', () => {
expect(findProjectPolicyAlert().exists()).toBe(true);
});
it('does show up on group page', () => {
- store.dispatch('setInitialState', { isGroupPage: true });
+ store.commit(SET_INITIAL_STATE, { isGroupPage: true });
return wrapper.vm.$nextTick().then(() => {
expect(findProjectPolicyAlert().exists()).toBe(false);
});
});
});
+ describe('API calls', () => {
+ it.each`
+ imageList | name | called
+ ${[]} | ${'foo'} | ${['requestImagesList']}
+ ${imagesListResponse.data} | ${undefined} | ${['requestImagesList']}
+ ${imagesListResponse.data} | ${'foo'} | ${undefined}
+ `(
+ 'with images equal $imageList and name $name dispatch calls $called',
+ ({ imageList, name, called }) => {
+ store.commit(SET_IMAGES_LIST_SUCCESS, imageList);
+ dispatchSpy.mockClear();
+ mountComponent({ mocks: { $route: { name } } });
+
+ expect(dispatchSpy.mock.calls[0]).toEqual(called);
+ },
+ );
+ });
+
describe('connection error', () => {
const config = {
characterError: true,
@@ -79,12 +116,13 @@ describe('List Page', () => {
helpPagePath: 'bar',
};
- beforeAll(() => {
- store.dispatch('setInitialState', config);
+ beforeEach(() => {
+ store.commit(SET_INITIAL_STATE, config);
+ mountComponent();
});
- afterAll(() => {
- store.dispatch('setInitialState', {});
+ afterEach(() => {
+ store.commit(SET_INITIAL_STATE, {});
});
it('should show an empty state', () => {
@@ -106,9 +144,12 @@ describe('List Page', () => {
});
describe('isLoading is true', () => {
- beforeAll(() => store.commit(SET_MAIN_LOADING, true));
+ beforeEach(() => {
+ store.commit(SET_MAIN_LOADING, true);
+ mountComponent();
+ });
- afterAll(() => store.commit(SET_MAIN_LOADING, false));
+ afterEach(() => store.commit(SET_MAIN_LOADING, false));
it('shows the skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
@@ -125,7 +166,9 @@ describe('List Page', () => {
describe('list is empty', () => {
beforeEach(() => {
- store.dispatch('receiveImagesListSuccess', { data: [] });
+ store.commit(SET_IMAGES_LIST_SUCCESS, []);
+ mountComponent();
+ return waitForPromises();
});
it('quick start is not visible', () => {
@@ -137,12 +180,13 @@ describe('List Page', () => {
});
describe('is group page is true', () => {
- beforeAll(() => {
- store.dispatch('setInitialState', { isGroupPage: true });
+ beforeEach(() => {
+ store.commit(SET_INITIAL_STATE, { isGroupPage: true });
+ mountComponent();
});
- afterAll(() => {
- store.dispatch('setInitialState', { isGroupPage: undefined });
+ afterEach(() => {
+ store.commit(SET_INITIAL_STATE, { isGroupPage: undefined });
});
it('group empty state is visible', () => {
@@ -152,50 +196,39 @@ describe('List Page', () => {
it('quick start is not visible', () => {
expect(findQuickStartDropdown().exists()).toBe(false);
});
+
+ it('list header is not visible', () => {
+ expect(findListHeader().exists()).toBe(false);
+ });
});
});
describe('list is not empty', () => {
- it('quick start is visible', () => {
- expect(findQuickStartDropdown().exists()).toBe(true);
- });
-
- describe('listElement', () => {
- let listElements;
- let firstElement;
-
+ describe('unfiltered state', () => {
beforeEach(() => {
- listElements = findRowItems();
- [firstElement] = store.state.images;
+ mountComponent();
});
- it('contains one list element for each image', () => {
- expect(listElements.length).toBe(store.state.images.length);
+ it('quick start is visible', () => {
+ expect(findQuickStartDropdown().exists()).toBe(true);
});
- it('contains a link to the details page', () => {
- const link = findDetailsLink();
- expect(link.html()).toContain(firstElement.path);
- expect(link.props('to').name).toBe('details');
+ it('list component is visible', () => {
+ expect(findImageList().exists()).toBe(true);
});
- it('contains a clipboard button', () => {
- const button = findClipboardButton();
- expect(button.exists()).toBe(true);
- expect(button.props('text')).toBe(firstElement.location);
- expect(button.props('title')).toBe(firstElement.location);
+ it('list header is visible', () => {
+ const header = findListHeader();
+ expect(header.exists()).toBe(true);
+ expect(header.text()).toBe(IMAGE_REPOSITORY_LIST_LABEL);
});
describe('delete image', () => {
- it('should be possible to delete a repo', () => {
- const deleteBtn = findDeleteBtn();
- expect(deleteBtn.exists()).toBe(true);
- });
-
+ const itemToDelete = { path: 'bar' };
it('should call deleteItem when confirming deletion', () => {
dispatchSpy.mockResolvedValue();
- findDeleteBtn().vm.$emit('click');
- expect(wrapper.vm.itemToDelete).not.toEqual({});
+ findImageList().vm.$emit('delete', itemToDelete);
+ expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
findDeleteModal().vm.$emit('ok');
expect(store.dispatch).toHaveBeenCalledWith(
'requestDeleteImage',
@@ -205,8 +238,8 @@ describe('List Page', () => {
it('should show a success alert when delete request is successful', () => {
dispatchSpy.mockResolvedValue();
- findDeleteBtn().vm.$emit('click');
- expect(wrapper.vm.itemToDelete).not.toEqual({});
+ findImageList().vm.$emit('delete', itemToDelete);
+ expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
return wrapper.vm.handleDeleteImage().then(() => {
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
@@ -218,8 +251,8 @@ describe('List Page', () => {
it('should show an error alert when delete request fails', () => {
dispatchSpy.mockRejectedValue();
- findDeleteBtn().vm.$emit('click');
- expect(wrapper.vm.itemToDelete).not.toEqual({});
+ findImageList().vm.$emit('delete', itemToDelete);
+ expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
return wrapper.vm.handleDeleteImage().then(() => {
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
@@ -229,71 +262,93 @@ describe('List Page', () => {
});
});
});
+ });
- describe('pagination', () => {
- it('exists', () => {
- expect(findPagination().exists()).toBe(true);
- });
+ describe('search', () => {
+ it('has a search box element', () => {
+ mountComponent();
+ const searchBox = findSearchBox();
+ expect(searchBox.exists()).toBe(true);
+ expect(searchBox.attributes('placeholder')).toBe(SEARCH_PLACEHOLDER_TEXT);
+ });
- it('is wired to the correct pagination props', () => {
- const pagination = findPagination();
- expect(pagination.props('perPage')).toBe(store.state.pagination.perPage);
- expect(pagination.props('totalItems')).toBe(store.state.pagination.total);
- expect(pagination.props('value')).toBe(store.state.pagination.page);
+ it('performs a search', () => {
+ mountComponent();
+ findSearchBox().vm.$emit('submit', 'foo');
+ expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', {
+ name: 'foo',
});
+ });
- it('fetch the data from the API when the v-model changes', () => {
- dispatchSpy.mockReturnValue();
- wrapper.setData({ currentPage: 2 });
- return wrapper.vm.$nextTick().then(() => {
- expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', { page: 2 });
- });
+ it('when search result is empty displays an empty search message', () => {
+ mountComponent();
+ store.commit(SET_IMAGES_LIST_SUCCESS, []);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findEmptySearchMessage().exists()).toBe(true);
});
});
});
- describe('modal', () => {
- it('exists', () => {
- expect(findDeleteModal().exists()).toBe(true);
- });
-
- it('contains a description with the path of the item to delete', () => {
- wrapper.setData({ itemToDelete: { path: 'foo' } });
- return wrapper.vm.$nextTick().then(() => {
- expect(findDeleteModal().html()).toContain('foo');
+ describe('pagination', () => {
+ it('pageChange event triggers the appropriate store function', () => {
+ mountComponent();
+ findImageList().vm.$emit('pageChange', 2);
+ expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', {
+ pagination: { page: 2 },
+ name: wrapper.vm.search,
});
});
});
+ });
- describe('tracking', () => {
- const testTrackingCall = action => {
- expect(Tracking.event).toHaveBeenCalledWith(undefined, action, {
- label: 'registry_repository_delete',
- });
- };
+ describe('modal', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
- beforeEach(() => {
- jest.spyOn(Tracking, 'event');
- dispatchSpy.mockResolvedValue();
- });
+ it('exists', () => {
+ expect(findDeleteModal().exists()).toBe(true);
+ });
- it('send an event when delete button is clicked', () => {
- const deleteBtn = findDeleteBtn();
- deleteBtn.vm.$emit('click');
- testTrackingCall('click_button');
+ it('contains a description with the path of the item to delete', () => {
+ wrapper.setData({ itemToDelete: { path: 'foo' } });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findDeleteModal().html()).toContain('foo');
});
+ });
+ });
- it('send an event when cancel is pressed on modal', () => {
- const deleteModal = findDeleteModal();
- deleteModal.vm.$emit('cancel');
- testTrackingCall('cancel_delete');
- });
+ describe('tracking', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
- it('send an event when confirm is clicked on modal', () => {
- const deleteModal = findDeleteModal();
- deleteModal.vm.$emit('ok');
- testTrackingCall('confirm_delete');
+ const testTrackingCall = action => {
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, action, {
+ label: 'registry_repository_delete',
});
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ dispatchSpy.mockResolvedValue();
+ });
+
+ it('send an event when delete button is clicked', () => {
+ findImageList().vm.$emit('delete', {});
+ testTrackingCall('click_button');
+ });
+
+ it('send an event when cancel is pressed on modal', () => {
+ const deleteModal = findDeleteModal();
+ deleteModal.vm.$emit('cancel');
+ testTrackingCall('cancel_delete');
+ });
+
+ it('send an event when confirm is clicked on modal', () => {
+ const deleteModal = findDeleteModal();
+ deleteModal.vm.$emit('ok');
+ testTrackingCall('confirm_delete');
});
});
});
diff --git a/spec/frontend/registry/explorer/stores/actions_spec.js b/spec/frontend/registry/explorer/stores/actions_spec.js
index 58f61a0e8c2..15f9db90910 100644
--- a/spec/frontend/registry/explorer/stores/actions_spec.js
+++ b/spec/frontend/registry/explorer/stores/actions_spec.js
@@ -191,7 +191,10 @@ describe('Actions RegistryExplorer Store', () => {
{
tagsPagination: {},
},
- [{ type: types.SET_MAIN_LOADING, payload: true }],
+ [
+ { type: types.SET_MAIN_LOADING, payload: true },
+ { type: types.SET_MAIN_LOADING, payload: false },
+ ],
[
{
type: 'setShowGarbageCollectionTip',
@@ -220,8 +223,7 @@ describe('Actions RegistryExplorer Store', () => {
{ type: types.SET_MAIN_LOADING, payload: false },
],
[],
- done,
- );
+ ).catch(() => done());
});
});
@@ -241,7 +243,10 @@ describe('Actions RegistryExplorer Store', () => {
{
tagsPagination: {},
},
- [{ type: types.SET_MAIN_LOADING, payload: true }],
+ [
+ { type: types.SET_MAIN_LOADING, payload: true },
+ { type: types.SET_MAIN_LOADING, payload: false },
+ ],
[
{
type: 'setShowGarbageCollectionTip',
@@ -273,8 +278,7 @@ describe('Actions RegistryExplorer Store', () => {
{ type: types.SET_MAIN_LOADING, payload: false },
],
[],
- done,
- );
+ ).catch(() => done());
});
});
@@ -311,9 +315,7 @@ describe('Actions RegistryExplorer Store', () => {
{ type: types.SET_MAIN_LOADING, payload: false },
],
[],
- ).catch(() => {
- done();
- });
+ ).catch(() => done());
});
});
});
diff --git a/spec/frontend/registry/explorer/stubs.js b/spec/frontend/registry/explorer/stubs.js
index 2c2c7587af9..0e178abfbed 100644
--- a/spec/frontend/registry/explorer/stubs.js
+++ b/spec/frontend/registry/explorer/stubs.js
@@ -9,3 +9,8 @@ export const GlEmptyState = {
template: '<div><slot name="description"></slot></div>',
name: 'GlEmptyStateSTub',
};
+
+export const RouterLink = {
+ template: `<div><slot></slot></div>`,
+ props: ['to'],
+};
diff --git a/spec/frontend/registry/settings/store/getters_spec.js b/spec/frontend/registry/settings/store/getters_spec.js
index 944057ebc9f..b781d09466c 100644
--- a/spec/frontend/registry/settings/store/getters_spec.js
+++ b/spec/frontend/registry/settings/store/getters_spec.js
@@ -4,9 +4,12 @@ import { formOptions } from '../../shared/mock_data';
describe('Getters registry settings store', () => {
const settings = {
+ enabled: true,
cadence: 'foo',
keep_n: 'bar',
older_than: 'baz',
+ name_regex: 'name-foo',
+ name_regex_keep: 'name-keep-bar',
};
describe.each`
@@ -29,6 +32,17 @@ describe('Getters registry settings store', () => {
});
});
+ describe('getSettings', () => {
+ it('returns the content of settings', () => {
+ const computedGetters = {
+ getCadence: settings.cadence,
+ getOlderThan: settings.older_than,
+ getKeepN: settings.keep_n,
+ };
+ expect(getters.getSettings({ settings }, computedGetters)).toEqual(settings);
+ });
+ });
+
describe('getIsEdited', () => {
it('returns false when original is equal to settings', () => {
const same = { foo: 'bar' };
diff --git a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap b/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap
index 6e7bc0491ce..a9034b81d2f 100644
--- a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap
+++ b/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap
@@ -117,11 +117,11 @@ exports[`Expiration Policy Form renders 1`] = `
<gl-form-group-stub
id="expiration-policy-name-matching-group"
invalid-feedback="The value of this input should be less than 255 characters"
- label="Docker tags with names matching this regex pattern will expire:"
label-align="right"
label-cols="3"
label-for="expiration-policy-name-matching"
>
+
<gl-form-textarea-stub
disabled="true"
id="expiration-policy-name-matching"
@@ -130,5 +130,21 @@ exports[`Expiration Policy Form renders 1`] = `
value=""
/>
</gl-form-group-stub>
+ <gl-form-group-stub
+ id="expiration-policy-keep-name-group"
+ invalid-feedback="The value of this input should be less than 255 characters"
+ label-align="right"
+ label-cols="3"
+ label-for="expiration-policy-keep-name"
+ >
+
+ <gl-form-textarea-stub
+ disabled="true"
+ id="expiration-policy-keep-name"
+ placeholder=""
+ trim=""
+ value=""
+ />
+ </gl-form-group-stub>
</div>
`;
diff --git a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
index 3782bfeaac4..4825351a6d3 100644
--- a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
+++ b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
@@ -40,12 +40,13 @@ describe('Expiration Policy Form', () => {
});
describe.each`
- elementName | modelName | value | disabledByToggle
- ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'}
- ${'interval'} | ${'older_than'} | ${'foo'} | ${'disabled'}
- ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'}
- ${'latest'} | ${'keep_n'} | ${'foo'} | ${'disabled'}
- ${'name-matching'} | ${'name_regex'} | ${'foo'} | ${'disabled'}
+ elementName | modelName | value | disabledByToggle
+ ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'}
+ ${'interval'} | ${'older_than'} | ${'foo'} | ${'disabled'}
+ ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'}
+ ${'latest'} | ${'keep_n'} | ${'foo'} | ${'disabled'}
+ ${'name-matching'} | ${'name_regex'} | ${'foo'} | ${'disabled'}
+ ${'keep-name'} | ${'name_regex_keep'} | ${'bar'} | ${'disabled'}
`(
`${FORM_ELEMENTS_ID_PREFIX}-$elementName form element`,
({ elementName, modelName, value, disabledByToggle }) => {
@@ -118,21 +119,26 @@ describe('Expiration Policy Form', () => {
${'schedule'}
${'latest'}
${'name-matching'}
+ ${'keep-name'}
`(`${FORM_ELEMENTS_ID_PREFIX}-$elementName is disabled`, ({ elementName }) => {
expect(findFormElements(elementName).attributes('disabled')).toBe('true');
});
});
- describe('form validation', () => {
+ describe.each`
+ modelName | elementName | stateVariable
+ ${'name_regex'} | ${'name-matching'} | ${'nameRegexState'}
+ ${'name_regex_keep'} | ${'keep-name'} | ${'nameKeepRegexState'}
+ `('regex textarea validation', ({ modelName, elementName, stateVariable }) => {
describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => {
const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(',');
beforeEach(() => {
- mountComponent({ value: { name_regex: invalidString } });
+ mountComponent({ value: { [modelName]: invalidString } });
});
- it('nameRegexState is false', () => {
- expect(wrapper.vm.nameRegexState).toBe(false);
+ it(`${stateVariable} is false`, () => {
+ expect(wrapper.vm.textAreaState[stateVariable]).toBe(false);
});
it('emit the @invalidated event', () => {
@@ -141,17 +147,20 @@ describe('Expiration Policy Form', () => {
});
it('if the user did not type validation is null', () => {
- mountComponent({ value: { name_regex: '' } });
+ mountComponent({ value: { [modelName]: '' } });
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.nameRegexState).toBe(null);
+ expect(wrapper.vm.textAreaState[stateVariable]).toBe(null);
expect(wrapper.emitted('validated')).toBeTruthy();
});
});
it(`if the user typed and is less than ${NAME_REGEX_LENGTH} state is true`, () => {
- mountComponent({ value: { name_regex: 'foo' } });
+ mountComponent({ value: { [modelName]: 'foo' } });
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.nameRegexState).toBe(true);
+ const formGroup = findFormGroup(elementName);
+ const formElement = findFormElements(elementName, formGroup);
+ expect(formGroup.attributes('state')).toBeTruthy();
+ expect(formElement.attributes('state')).toBeTruthy();
});
});
});
diff --git a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js
new file mode 100644
index 00000000000..1b938c93df8
--- /dev/null
+++ b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js
@@ -0,0 +1,94 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
+import RelatedMergeRequests from '~/related_merge_requests/components/related_merge_requests.vue';
+import createStore from '~/related_merge_requests/store/index';
+
+const FIXTURE_PATH = 'issues/related_merge_requests.json';
+const API_ENDPOINT = '/api/v4/projects/2/issues/33/related_merge_requests';
+const localVue = createLocalVue();
+
+describe('RelatedMergeRequests', () => {
+ let wrapper;
+ let mock;
+ let mockData;
+
+ beforeEach(done => {
+ loadFixtures(FIXTURE_PATH);
+ mockData = getJSONFixture(FIXTURE_PATH);
+
+ // put the fixture in DOM as the component expects
+ document.body.innerHTML = `<div id="js-issuable-app-initial-data">${JSON.stringify(
+ mockData,
+ )}</div>`;
+
+ mock = new MockAdapter(axios);
+ mock.onGet(`${API_ENDPOINT}?per_page=100`).reply(200, mockData, { 'x-total': 2 });
+
+ wrapper = mount(localVue.extend(RelatedMergeRequests), {
+ localVue,
+ store: createStore(),
+ propsData: {
+ endpoint: API_ENDPOINT,
+ projectNamespace: 'gitlab-org',
+ projectPath: 'gitlab-ce',
+ },
+ });
+
+ setImmediate(done);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ describe('methods', () => {
+ describe('getAssignees', () => {
+ const assignees = [{ name: 'foo' }, { name: 'bar' }];
+
+ describe('when there is assignees array', () => {
+ it('should return assignees array', () => {
+ const mr = { assignees };
+
+ expect(wrapper.vm.getAssignees(mr)).toEqual(assignees);
+ });
+ });
+
+ it('should return an array with single assingee', () => {
+ const mr = { assignee: assignees[0] };
+
+ expect(wrapper.vm.getAssignees(mr)).toEqual([assignees[0]]);
+ });
+
+ it('should return empty array when assignee is not set', () => {
+ expect(wrapper.vm.getAssignees({})).toEqual([]);
+ expect(wrapper.vm.getAssignees({ assignee: null })).toEqual([]);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render related merge request items', () => {
+ expect(wrapper.find('.js-items-count').text()).toEqual('2');
+ expect(wrapper.findAll(RelatedIssuableItem).length).toEqual(2);
+
+ const props = wrapper
+ .findAll(RelatedIssuableItem)
+ .at(1)
+ .props();
+ const data = mockData[1];
+
+ expect(props.idKey).toEqual(data.id);
+ expect(props.pathIdSeparator).toEqual('!');
+ expect(props.pipelineStatus).toBe(data.head_pipeline.detailed_status);
+ expect(props.assignees).toEqual([data.assignee]);
+ expect(props.isMergeRequest).toBe(true);
+ expect(props.confidential).toEqual(false);
+ expect(props.title).toEqual(data.title);
+ expect(props.state).toEqual(data.state);
+ expect(props.createdAt).toEqual(data.created_at);
+ });
+ });
+});
diff --git a/spec/frontend/related_merge_requests/store/actions_spec.js b/spec/frontend/related_merge_requests/store/actions_spec.js
new file mode 100644
index 00000000000..26c5977cb5f
--- /dev/null
+++ b/spec/frontend/related_merge_requests/store/actions_spec.js
@@ -0,0 +1,111 @@
+import MockAdapter from 'axios-mock-adapter';
+import createFlash from '~/flash';
+import testAction from 'helpers/vuex_action_helper';
+import axios from '~/lib/utils/axios_utils';
+import * as types from '~/related_merge_requests/store/mutation_types';
+import * as actions from '~/related_merge_requests/store/actions';
+
+jest.mock('~/flash');
+
+describe('RelatedMergeRequest store actions', () => {
+ let state;
+ let mock;
+
+ beforeEach(() => {
+ state = {
+ apiEndpoint: '/api/related_merge_requests',
+ };
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('setInitialState', () => {
+ it('commits types.SET_INITIAL_STATE with given props', done => {
+ const props = { a: 1, b: 2 };
+
+ testAction(
+ actions.setInitialState,
+ props,
+ {},
+ [{ type: types.SET_INITIAL_STATE, payload: props }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestData', () => {
+ it('commits types.REQUEST_DATA', done => {
+ testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], [], done);
+ });
+ });
+
+ describe('receiveDataSuccess', () => {
+ it('commits types.RECEIVE_DATA_SUCCESS with data', done => {
+ const data = { a: 1, b: 2 };
+
+ testAction(
+ actions.receiveDataSuccess,
+ data,
+ {},
+ [{ type: types.RECEIVE_DATA_SUCCESS, payload: data }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveDataError', () => {
+ it('commits types.RECEIVE_DATA_ERROR', done => {
+ testAction(
+ actions.receiveDataError,
+ null,
+ {},
+ [{ type: types.RECEIVE_DATA_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchMergeRequests', () => {
+ describe('for a successful request', () => {
+ it('should dispatch success action', done => {
+ const data = { a: 1 };
+ mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(200, data, { 'x-total': 2 });
+
+ testAction(
+ actions.fetchMergeRequests,
+ null,
+ state,
+ [],
+ [{ type: 'requestData' }, { type: 'receiveDataSuccess', payload: { data, total: 2 } }],
+ done,
+ );
+ });
+ });
+
+ describe('for a failing request', () => {
+ it('should dispatch error action', done => {
+ mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(400);
+
+ testAction(
+ actions.fetchMergeRequests,
+ null,
+ state,
+ [],
+ [{ type: 'requestData' }, { type: 'receiveDataError' }],
+ () => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(expect.stringMatching('Something went wrong'));
+
+ done();
+ },
+ );
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/related_merge_requests/store/mutations_spec.js b/spec/frontend/related_merge_requests/store/mutations_spec.js
index 21b6e26376b..21b6e26376b 100644
--- a/spec/javascripts/related_merge_requests/store/mutations_spec.js
+++ b/spec/frontend/related_merge_requests/store/mutations_spec.js
diff --git a/spec/frontend/releases/components/app_edit_spec.js b/spec/frontend/releases/components/app_edit_spec.js
index 09bafe4aa9b..4450b047acd 100644
--- a/spec/frontend/releases/components/app_edit_spec.js
+++ b/spec/frontend/releases/components/app_edit_spec.js
@@ -1,11 +1,13 @@
import Vuex from 'vuex';
import { mount } from '@vue/test-utils';
import ReleaseEditApp from '~/releases/components/app_edit.vue';
-import { release as originalRelease } from '../mock_data';
+import { release as originalRelease, milestones as originalMilestones } from '../mock_data';
import * as commonUtils from '~/lib/utils/common_utils';
import { BACK_URL_PARAM } from '~/releases/constants';
import AssetLinksForm from '~/releases/components/asset_links_form.vue';
import { merge } from 'lodash';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
describe('Release edit component', () => {
let wrapper;
@@ -13,6 +15,7 @@ describe('Release edit component', () => {
let actions;
let getters;
let state;
+ let mock;
const factory = ({ featureFlags = {}, store: storeUpdates = {} } = {}) => {
state = {
@@ -20,6 +23,7 @@ describe('Release edit component', () => {
markdownDocsPath: 'path/to/markdown/docs',
updateReleaseApiDocsPath: 'path/to/update/release/api/docs',
releasesPagePath: 'path/to/releases/page',
+ projectId: '8',
};
actions = {
@@ -62,8 +66,11 @@ describe('Release edit component', () => {
};
beforeEach(() => {
+ mock = new MockAdapter(axios);
gon.api_version = 'v4';
+ mock.onGet('/api/v4/projects/8/milestones').reply(200, originalMilestones);
+
release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true });
});
diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js
index c63637c4cae..b91cfb82b65 100644
--- a/spec/frontend/releases/components/release_block_footer_spec.js
+++ b/spec/frontend/releases/components/release_block_footer_spec.js
@@ -3,13 +3,17 @@ import { GlLink } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue';
import Icon from '~/vue_shared/components/icon.vue';
-import { release } from '../mock_data';
+import { release as originalRelease } from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { cloneDeep } from 'lodash';
+
+const mockFutureDate = new Date(9999, 0, 0).toISOString();
+let mockIsFutureRelease = false;
jest.mock('~/vue_shared/mixins/timeago', () => ({
methods: {
timeFormatted() {
- return '7 fortnights ago';
+ return mockIsFutureRelease ? 'in 1 month' : '7 fortnights ago';
},
tooltipTitle() {
return 'February 30, 2401';
@@ -19,12 +23,12 @@ jest.mock('~/vue_shared/mixins/timeago', () => ({
describe('Release block footer', () => {
let wrapper;
- let releaseClone;
+ let release;
const factory = (props = {}) => {
wrapper = mount(ReleaseBlockFooter, {
propsData: {
- ...convertObjectPropsToCamelCase(releaseClone, { deep: true }),
+ ...convertObjectPropsToCamelCase(release, { deep: true }),
...props,
},
});
@@ -33,11 +37,13 @@ describe('Release block footer', () => {
};
beforeEach(() => {
- releaseClone = JSON.parse(JSON.stringify(release));
+ release = cloneDeep(originalRelease);
});
afterEach(() => {
wrapper.destroy();
+ wrapper = null;
+ mockIsFutureRelease = false;
});
const commitInfoSection = () => wrapper.find('.js-commit-info');
@@ -60,8 +66,8 @@ describe('Release block footer', () => {
const commitLink = commitInfoSectionLink();
expect(commitLink.exists()).toBe(true);
- expect(commitLink.text()).toBe(releaseClone.commit.short_id);
- expect(commitLink.attributes('href')).toBe(releaseClone.commit_path);
+ expect(commitLink.text()).toBe(release.commit.short_id);
+ expect(commitLink.attributes('href')).toBe(release.commit_path);
});
it('renders the tag icon', () => {
@@ -75,28 +81,60 @@ describe('Release block footer', () => {
const commitLink = tagInfoSection().find(GlLink);
expect(commitLink.exists()).toBe(true);
- expect(commitLink.text()).toBe(releaseClone.tag_name);
- expect(commitLink.attributes('href')).toBe(releaseClone.tag_path);
+ expect(commitLink.text()).toBe(release.tag_name);
+ expect(commitLink.attributes('href')).toBe(release.tag_path);
});
it('renders the author and creation time info', () => {
expect(trimText(authorDateInfoSection().text())).toBe(
- `Created 7 fortnights ago by ${releaseClone.author.username}`,
+ `Created 7 fortnights ago by ${release.author.username}`,
);
});
+ describe('when the release date is in the past', () => {
+ it('prefixes the creation info with "Created"', () => {
+ expect(trimText(authorDateInfoSection().text())).toEqual(expect.stringMatching(/^Created/));
+ });
+ });
+
+ describe('renders the author and creation time info with future release date', () => {
+ beforeEach(() => {
+ mockIsFutureRelease = true;
+ factory({ releasedAt: mockFutureDate });
+ });
+
+ it('renders the release date without the author name', () => {
+ expect(trimText(authorDateInfoSection().text())).toBe(
+ `Will be created in 1 month by ${release.author.username}`,
+ );
+ });
+ });
+
+ describe('when the release date is in the future', () => {
+ beforeEach(() => {
+ mockIsFutureRelease = true;
+ factory({ releasedAt: mockFutureDate });
+ });
+
+ it('prefixes the creation info with "Will be created"', () => {
+ expect(trimText(authorDateInfoSection().text())).toEqual(
+ expect.stringMatching(/^Will be created/),
+ );
+ });
+ });
+
it("renders the author's avatar image", () => {
const avatarImg = authorDateInfoSection().find('img');
expect(avatarImg.exists()).toBe(true);
- expect(avatarImg.attributes('src')).toBe(releaseClone.author.avatar_url);
+ expect(avatarImg.attributes('src')).toBe(release.author.avatar_url);
});
it("renders a link to the author's profile", () => {
const authorLink = authorDateInfoSection().find(GlLink);
expect(authorLink.exists()).toBe(true);
- expect(authorLink.attributes('href')).toBe(releaseClone.author.web_url);
+ expect(authorLink.attributes('href')).toBe(release.author.web_url);
});
});
@@ -113,7 +151,7 @@ describe('Release block footer', () => {
it('renders the commit SHA as plain text (instead of a link)', () => {
expect(commitInfoSectionLink().exists()).toBe(false);
- expect(commitInfoSection().text()).toBe(releaseClone.commit.short_id);
+ expect(commitInfoSection().text()).toBe(release.commit.short_id);
});
});
@@ -130,7 +168,7 @@ describe('Release block footer', () => {
it('renders the tag name as plain text (instead of a link)', () => {
expect(tagInfoSectionLink().exists()).toBe(false);
- expect(tagInfoSection().text()).toBe(releaseClone.tag_name);
+ expect(tagInfoSection().text()).toBe(release.tag_name);
});
});
@@ -138,7 +176,18 @@ describe('Release block footer', () => {
beforeEach(() => factory({ author: undefined }));
it('renders the release date without the author name', () => {
- expect(trimText(authorDateInfoSection().text())).toBe('Created 7 fortnights ago');
+ expect(trimText(authorDateInfoSection().text())).toBe(`Created 7 fortnights ago`);
+ });
+ });
+
+ describe('future release without any author info', () => {
+ beforeEach(() => {
+ mockIsFutureRelease = true;
+ factory({ author: undefined, releasedAt: mockFutureDate });
+ });
+
+ it('renders the release date without the author name', () => {
+ expect(trimText(authorDateInfoSection().text())).toBe(`Will be created in 1 month`);
});
});
@@ -147,7 +196,7 @@ describe('Release block footer', () => {
it('renders the author name without the release date', () => {
expect(trimText(authorDateInfoSection().text())).toBe(
- `Created by ${releaseClone.author.username}`,
+ `Created by ${release.author.username}`,
);
});
});
diff --git a/spec/frontend/releases/components/release_block_metadata_spec.js b/spec/frontend/releases/components/release_block_metadata_spec.js
new file mode 100644
index 00000000000..cbe478bfa1f
--- /dev/null
+++ b/spec/frontend/releases/components/release_block_metadata_spec.js
@@ -0,0 +1,67 @@
+import { mount } from '@vue/test-utils';
+import { trimText } from 'helpers/text_helper';
+import ReleaseBlockMetadata from '~/releases/components/release_block_metadata.vue';
+import { release as originalRelease } from '../mock_data';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { cloneDeep } from 'lodash';
+
+const mockFutureDate = new Date(9999, 0, 0).toISOString();
+let mockIsFutureRelease = false;
+
+jest.mock('~/vue_shared/mixins/timeago', () => ({
+ methods: {
+ timeFormatted() {
+ return mockIsFutureRelease ? 'in 1 month' : '7 fortnights ago';
+ },
+ tooltipTitle() {
+ return 'February 30, 2401';
+ },
+ },
+}));
+
+describe('Release block metadata', () => {
+ let wrapper;
+ let release;
+
+ const factory = (releaseUpdates = {}) => {
+ wrapper = mount(ReleaseBlockMetadata, {
+ propsData: {
+ release: {
+ ...convertObjectPropsToCamelCase(release, { deep: true }),
+ ...releaseUpdates,
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ release = cloneDeep(originalRelease);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ mockIsFutureRelease = false;
+ });
+
+ const findReleaseDateInfo = () => wrapper.find('.js-release-date-info');
+
+ describe('with all props provided', () => {
+ beforeEach(() => factory());
+
+ it('renders the release time info', () => {
+ expect(trimText(findReleaseDateInfo().text())).toBe(`released 7 fortnights ago`);
+ });
+ });
+
+ describe('with a future release date', () => {
+ beforeEach(() => {
+ mockIsFutureRelease = true;
+ factory({ releasedAt: mockFutureDate });
+ });
+
+ it('renders the release date without the author name', () => {
+ expect(trimText(findReleaseDateInfo().text())).toBe(`will be released in 1 month`);
+ });
+ });
+});
diff --git a/spec/frontend/releases/components/release_block_milestone_info_spec.js b/spec/frontend/releases/components/release_block_milestone_info_spec.js
index 0b65b6cab96..0e79c45b337 100644
--- a/spec/frontend/releases/components/release_block_milestone_info_spec.js
+++ b/spec/frontend/releases/components/release_block_milestone_info_spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils';
-import { GlProgressBar, GlLink, GlBadge, GlDeprecatedButton } from '@gitlab/ui';
+import { GlProgressBar, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
import ReleaseBlockMilestoneInfo from '~/releases/components/release_block_milestone_info.vue';
import { milestones as originalMilestones } from '../mock_data';
@@ -106,7 +106,7 @@ describe('Release block milestone info', () => {
const clickShowMoreFewerButton = () => {
milestoneListContainer()
- .find(GlDeprecatedButton)
+ .find(GlButton)
.trigger('click');
return wrapper.vm.$nextTick();
diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js
index 9846fcb65eb..19119d99f3c 100644
--- a/spec/frontend/releases/components/release_block_spec.js
+++ b/spec/frontend/releases/components/release_block_spec.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import { mount } from '@vue/test-utils';
-import { first } from 'underscore';
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';
@@ -80,11 +79,11 @@ describe('Release block', () => {
);
expect(wrapper.find('.js-sources-dropdown li a').attributes().href).toEqual(
- first(release.assets.sources).url,
+ release.assets.sources[0].url,
);
expect(wrapper.find('.js-sources-dropdown li a').text()).toContain(
- first(release.assets.sources).format,
+ release.assets.sources[0].format,
);
});
@@ -92,12 +91,10 @@ describe('Release block', () => {
expect(wrapper.findAll('.js-assets-list li').length).toEqual(release.assets.links.length);
expect(wrapper.find('.js-assets-list li a').attributes().href).toEqual(
- first(release.assets.links).directAssetUrl,
+ release.assets.links[0].directAssetUrl,
);
- expect(wrapper.find('.js-assets-list li a').text()).toContain(
- first(release.assets.links).name,
- );
+ expect(wrapper.find('.js-assets-list li a').text()).toContain(release.assets.links[0].name);
});
it('renders author avatar', () => {
@@ -264,7 +261,7 @@ describe('Release block', () => {
});
it('renders a link to the milestone with a tooltip', () => {
- const milestone = first(release.milestones);
+ const milestone = release.milestones[0];
const milestoneLink = wrapper.find('.js-milestone-link');
expect(milestoneLink.exists()).toBe(true);
diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js
index 4a1790adb09..854f06821be 100644
--- a/spec/frontend/releases/stores/modules/detail/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js
@@ -130,6 +130,15 @@ describe('Release detail actions', () => {
});
});
+ describe('updateReleaseMilestones', () => {
+ it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => {
+ const newReleaseMilestones = ['v0.0', 'v0.1'];
+ return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [
+ { type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones },
+ ]);
+ });
+ });
+
describe('requestUpdateRelease', () => {
it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () =>
testAction(actions.requestUpdateRelease, undefined, state, [
@@ -143,7 +152,7 @@ describe('Release detail actions', () => {
{ type: types.RECEIVE_UPDATE_RELEASE_SUCCESS },
]));
- describe('when the releaseShowPage feature flag is enabled', () => {
+ it('redirects to the releases page if releaseShowPage feature flag is enabled', () => {
const rootState = { featureFlags: { releaseShowPage: true } };
const updatedState = merge({}, state, {
releasesPagePath: 'path/to/releases/page',
@@ -248,6 +257,7 @@ describe('Release detail actions', () => {
{
name: state.release.name,
description: state.release.description,
+ milestones: state.release.milestones.map(milestone => milestone.title),
},
],
]);
diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
index cb5a1880b0c..f3f7ca797b4 100644
--- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
@@ -1,10 +1,3 @@
-/* eslint-disable jest/valid-describe */
-/*
- * ESLint disable directive ↑ can be removed once
- * https://github.com/jest-community/eslint-plugin-jest/issues/203
- * is resolved
- */
-
import createState from '~/releases/stores/modules/detail/state';
import mutations from '~/releases/stores/modules/detail/mutations';
import * as types from '~/releases/stores/modules/detail/mutation_types';
@@ -27,7 +20,7 @@ describe('Release detail mutations', () => {
release = convertObjectPropsToCamelCase(originalRelease);
});
- describe(types.REQUEST_RELEASE, () => {
+ describe(`${types.REQUEST_RELEASE}`, () => {
it('set state.isFetchingRelease to true', () => {
mutations[types.REQUEST_RELEASE](state);
@@ -35,7 +28,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.RECEIVE_RELEASE_SUCCESS, () => {
+ describe(`${types.RECEIVE_RELEASE_SUCCESS}`, () => {
it('handles a successful response from the server', () => {
mutations[types.RECEIVE_RELEASE_SUCCESS](state, release);
@@ -49,7 +42,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.RECEIVE_RELEASE_ERROR, () => {
+ describe(`${types.RECEIVE_RELEASE_ERROR}`, () => {
it('handles an unsuccessful response from the server', () => {
const error = { message: 'An error occurred!' };
mutations[types.RECEIVE_RELEASE_ERROR](state, error);
@@ -62,7 +55,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.UPDATE_RELEASE_TITLE, () => {
+ describe(`${types.UPDATE_RELEASE_TITLE}`, () => {
it("updates the release's title", () => {
state.release = release;
const newTitle = 'The new release title';
@@ -72,7 +65,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.UPDATE_RELEASE_NOTES, () => {
+ describe(`${types.UPDATE_RELEASE_NOTES}`, () => {
it("updates the release's notes", () => {
state.release = release;
const newNotes = 'The new release notes';
@@ -82,7 +75,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.REQUEST_UPDATE_RELEASE, () => {
+ describe(`${types.REQUEST_UPDATE_RELEASE}`, () => {
it('set state.isUpdatingRelease to true', () => {
mutations[types.REQUEST_UPDATE_RELEASE](state);
@@ -90,7 +83,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.RECEIVE_UPDATE_RELEASE_SUCCESS, () => {
+ describe(`${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => {
it('handles a successful response from the server', () => {
mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state, release);
@@ -100,7 +93,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.RECEIVE_UPDATE_RELEASE_ERROR, () => {
+ describe(`${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => {
it('handles an unsuccessful response from the server', () => {
const error = { message: 'An error occurred!' };
mutations[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error);
@@ -111,7 +104,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.ADD_EMPTY_ASSET_LINK, () => {
+ describe(`${types.ADD_EMPTY_ASSET_LINK}`, () => {
it('adds a new, empty link object to the release', () => {
state.release = release;
@@ -130,7 +123,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.UPDATE_ASSET_LINK_URL, () => {
+ describe(`${types.UPDATE_ASSET_LINK_URL}`, () => {
it('updates an asset link with a new URL', () => {
state.release = release;
@@ -145,7 +138,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.UPDATE_ASSET_LINK_NAME, () => {
+ describe(`${types.UPDATE_ASSET_LINK_NAME}`, () => {
it('updates an asset link with a new name', () => {
state.release = release;
@@ -160,7 +153,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.REMOVE_ASSET_LINK, () => {
+ describe(`${types.REMOVE_ASSET_LINK}`, () => {
it('removes an asset link from the release', () => {
state.release = release;
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
new file mode 100644
index 00000000000..a036588596a
--- /dev/null
+++ b/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js
@@ -0,0 +1,126 @@
+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 { mockReport } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Grouped accessibility reports app', () => {
+ const Component = localVue.extend(GroupedAccessibilityReportsApp);
+ let wrapper;
+ let mockStore;
+
+ const mountComponent = () => {
+ wrapper = mount(Component, {
+ store: mockStore,
+ localVue,
+ propsData: {
+ endpoint: 'endpoint.json',
+ },
+ methods: {
+ fetchReport: () => {},
+ },
+ });
+ };
+
+ const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]');
+
+ beforeEach(() => {
+ mockStore = store();
+ mountComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ mockStore.state.isLoading = true;
+ mountComponent();
+ });
+
+ it('renders loading state', () => {
+ expect(findHeader().text()).toEqual('Accessibility scanning results are being parsed');
+ });
+ });
+
+ describe('with error', () => {
+ beforeEach(() => {
+ mockStore.state.isLoading = false;
+ mockStore.state.hasError = true;
+ mountComponent();
+ });
+
+ it('renders error state', () => {
+ expect(findHeader().text()).toEqual('Accessibility scanning failed loading results');
+ });
+ });
+
+ describe('with a report', () => {
+ describe('with no issues', () => {
+ beforeEach(() => {
+ mockStore.state.report = {
+ summary: {
+ errored: 0,
+ },
+ };
+ });
+
+ it('renders no issues header', () => {
+ expect(findHeader().text()).toContain(
+ 'Accessibility scanning detected no issues for the source branch only',
+ );
+ });
+ });
+
+ describe('with one issue', () => {
+ beforeEach(() => {
+ mockStore.state.report = {
+ summary: {
+ errored: 1,
+ },
+ };
+ });
+
+ it('renders one issue header', () => {
+ expect(findHeader().text()).toContain(
+ 'Accessibility scanning detected 1 issue for the source branch only',
+ );
+ });
+ });
+
+ describe('with multiple issues', () => {
+ beforeEach(() => {
+ mockStore.state.report = {
+ summary: {
+ errored: 2,
+ },
+ };
+ });
+
+ it('renders multiple issues header', () => {
+ expect(findHeader().text()).toContain(
+ 'Accessibility scanning detected 2 issues for the source branch only',
+ );
+ });
+ });
+
+ describe('with issues to show', () => {
+ beforeEach(() => {
+ mockStore.state.report = mockReport;
+ });
+
+ it('renders custom accessibility issue body', () => {
+ const issueBody = wrapper.find(AccessibilityIssueBody);
+
+ expect(issueBody.props('issue').code).toBe(mockReport.new_errors[0].code);
+ expect(issueBody.props('issue').message).toBe(mockReport.new_errors[0].message);
+ expect(issueBody.props('isNew')).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/reports/accessibility_report/mock_data.js b/spec/frontend/reports/accessibility_report/mock_data.js
new file mode 100644
index 00000000000..f8e832c1ce5
--- /dev/null
+++ b/spec/frontend/reports/accessibility_report/mock_data.js
@@ -0,0 +1,55 @@
+export const mockReport = {
+ status: 'failed',
+ summary: {
+ total: 2,
+ resolved: 0,
+ errored: 2,
+ },
+ new_errors: [
+ {
+ code: 'WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail',
+ type: 'error',
+ typeCode: 1,
+ message:
+ 'This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 3.84:1. Recommendation: change text colour to #767676.',
+ context: '<a href="/stages-devops-lifecycle/" class="main-nav-link">Product</a>',
+ selector: '#main-nav > div:nth-child(2) > ul > li:nth-child(1) > a',
+ runner: 'htmlcs',
+ runnerExtras: {},
+ },
+ ],
+ new_notes: [],
+ new_warnings: [],
+ resolved_errors: [
+ {
+ code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent',
+ type: 'error',
+ typeCode: 1,
+ message:
+ 'Anchor element found with a valid href attribute, but no link content has been supplied.',
+ context: '<a href="/" class="navbar-brand animated"><svg height="36" viewBox="0 0 1...</a>',
+ selector: '#main-nav > div:nth-child(1) > a',
+ runner: 'htmlcs',
+ runnerExtras: {},
+ },
+ ],
+ resolved_notes: [],
+ resolved_warnings: [],
+ existing_errors: [
+ {
+ code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent',
+ type: 'error',
+ typeCode: 1,
+ message:
+ 'Anchor element found with a valid href attribute, but no link content has been supplied.',
+ context: '<a href="/" class="navbar-brand animated"><svg height="36" viewBox="0 0 1...</a>',
+ selector: '#main-nav > div:nth-child(1) > a',
+ runner: 'htmlcs',
+ runnerExtras: {},
+ },
+ ],
+ existing_notes: [],
+ existing_warnings: [],
+};
+
+export default () => {};
diff --git a/spec/frontend/reports/accessibility_report/store/actions_spec.js b/spec/frontend/reports/accessibility_report/store/actions_spec.js
new file mode 100644
index 00000000000..129a5bade86
--- /dev/null
+++ b/spec/frontend/reports/accessibility_report/store/actions_spec.js
@@ -0,0 +1,121 @@
+import axios from '~/lib/utils/axios_utils';
+import MockAdapter from 'axios-mock-adapter';
+import * as actions from '~/reports/accessibility_report/store/actions';
+import * as types from '~/reports/accessibility_report/store/mutation_types';
+import createStore from '~/reports/accessibility_report/store';
+import { TEST_HOST } from 'spec/test_constants';
+import testAction from 'helpers/vuex_action_helper';
+import { mockReport } from '../mock_data';
+
+describe('Accessibility Reports actions', () => {
+ let localState;
+ let localStore;
+
+ beforeEach(() => {
+ localStore = createStore();
+ localState = localStore.state;
+ });
+
+ describe('setEndpoints', () => {
+ it('should commit SET_ENDPOINTS mutation', done => {
+ const endpoint = 'endpoint.json';
+
+ testAction(
+ actions.setEndpoint,
+ endpoint,
+ localState,
+ [{ type: types.SET_ENDPOINT, payload: endpoint }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchReport', () => {
+ let mock;
+
+ beforeEach(() => {
+ localState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ actions.stopPolling();
+ actions.clearEtagPoll();
+ });
+
+ describe('success', () => {
+ it('should commit REQUEST_REPORT mutation and dispatch receiveReportSuccess', done => {
+ const data = { report: { summary: {} } };
+ mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, data);
+
+ testAction(
+ actions.fetchReport,
+ null,
+ localState,
+ [{ type: types.REQUEST_REPORT }],
+ [
+ {
+ payload: { status: 200, data },
+ type: 'receiveReportSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('should commit REQUEST_REPORT and RECEIVE_REPORT_ERROR mutations', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
+
+ testAction(
+ actions.fetchReport,
+ null,
+ localState,
+ [{ type: types.REQUEST_REPORT }],
+ [{ type: 'receiveReportError' }],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('receiveReportSuccess', () => {
+ it('should commit RECEIVE_REPORT_SUCCESS mutation with 200', done => {
+ testAction(
+ actions.receiveReportSuccess,
+ { status: 200, data: mockReport },
+ localState,
+ [{ type: types.RECEIVE_REPORT_SUCCESS, payload: mockReport }],
+ [{ type: 'stopPolling' }],
+ done,
+ );
+ });
+
+ it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', done => {
+ testAction(
+ actions.receiveReportSuccess,
+ { status: 204, data: mockReport },
+ localState,
+ [],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveReportError', () => {
+ it('should commit RECEIVE_REPORT_ERROR mutation', done => {
+ testAction(
+ actions.receiveReportError,
+ null,
+ localState,
+ [{ type: types.RECEIVE_REPORT_ERROR }],
+ [{ type: 'stopPolling' }],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/reports/accessibility_report/store/getters_spec.js b/spec/frontend/reports/accessibility_report/store/getters_spec.js
new file mode 100644
index 00000000000..d74c71cfa09
--- /dev/null
+++ b/spec/frontend/reports/accessibility_report/store/getters_spec.js
@@ -0,0 +1,149 @@
+import * as getters from '~/reports/accessibility_report/store/getters';
+import createStore from '~/reports/accessibility_report/store';
+import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '~/reports/constants';
+
+describe('Accessibility reports store getters', () => {
+ let localState;
+ let localStore;
+
+ beforeEach(() => {
+ localStore = createStore();
+ localState = localStore.state;
+ });
+
+ describe('summaryStatus', () => {
+ describe('when summary is loading', () => {
+ it('returns loading status', () => {
+ localState.isLoading = true;
+
+ expect(getters.summaryStatus(localState)).toEqual(LOADING);
+ });
+ });
+
+ describe('when summary has error', () => {
+ it('returns error status', () => {
+ localState.hasError = true;
+
+ expect(getters.summaryStatus(localState)).toEqual(ERROR);
+ });
+ });
+
+ describe('when summary has failed status', () => {
+ it('returns loading status', () => {
+ localState.status = STATUS_FAILED;
+
+ expect(getters.summaryStatus(localState)).toEqual(ERROR);
+ });
+ });
+
+ describe('when summary has successfully loaded', () => {
+ it('returns loading status', () => {
+ expect(getters.summaryStatus(localState)).toEqual(SUCCESS);
+ });
+ });
+ });
+
+ describe('groupedSummaryText', () => {
+ describe('when state is loading', () => {
+ it('returns the loading summary message', () => {
+ localState.isLoading = true;
+ const result = 'Accessibility scanning results are being parsed';
+
+ expect(getters.groupedSummaryText(localState)).toEqual(result);
+ });
+ });
+
+ describe('when state has error', () => {
+ it('returns the error summary message', () => {
+ localState.hasError = true;
+ const result = 'Accessibility scanning failed loading results';
+
+ expect(getters.groupedSummaryText(localState)).toEqual(result);
+ });
+ });
+
+ describe('when state has successfully loaded', () => {
+ describe('when report has errors', () => {
+ it('returns summary message containing number of errors', () => {
+ localState.report = {
+ summary: {
+ errored: 2,
+ },
+ };
+ const result = 'Accessibility scanning detected 2 issues for the source branch only';
+
+ expect(getters.groupedSummaryText(localState)).toEqual(result);
+ });
+ });
+
+ describe('when report has no errors', () => {
+ it('returns summary message containing no errors', () => {
+ localState.report = {
+ summary: {
+ errored: 0,
+ },
+ };
+ const result = 'Accessibility scanning detected no issues for the source branch only';
+
+ expect(getters.groupedSummaryText(localState)).toEqual(result);
+ });
+ });
+ });
+ });
+
+ describe('shouldRenderIssuesList', () => {
+ describe('when has issues to render', () => {
+ it('returns true', () => {
+ localState.report = {
+ existing_errors: [{ name: 'Issue' }],
+ };
+
+ expect(getters.shouldRenderIssuesList(localState)).toEqual(true);
+ });
+ });
+
+ describe('when does not have issues to render', () => {
+ it('returns false', () => {
+ localState.report = {
+ status: 'success',
+ summary: { errored: 0 },
+ };
+
+ expect(getters.shouldRenderIssuesList(localState)).toEqual(false);
+ });
+ });
+ });
+
+ describe('unresolvedIssues', () => {
+ it('returns the array unresolved errors', () => {
+ localState.report = {
+ existing_errors: [1],
+ };
+ const result = [1];
+
+ expect(getters.unresolvedIssues(localState)).toEqual(result);
+ });
+ });
+
+ describe('resolvedIssues', () => {
+ it('returns array of resolved errors', () => {
+ localState.report = {
+ resolved_errors: [1],
+ };
+ const result = [1];
+
+ expect(getters.resolvedIssues(localState)).toEqual(result);
+ });
+ });
+
+ describe('newIssues', () => {
+ it('returns array of new errors', () => {
+ localState.report = {
+ new_errors: [1],
+ };
+ const result = [1];
+
+ expect(getters.newIssues(localState)).toEqual(result);
+ });
+ });
+});
diff --git a/spec/frontend/reports/accessibility_report/store/mutations_spec.js b/spec/frontend/reports/accessibility_report/store/mutations_spec.js
new file mode 100644
index 00000000000..a4e9571b721
--- /dev/null
+++ b/spec/frontend/reports/accessibility_report/store/mutations_spec.js
@@ -0,0 +1,64 @@
+import mutations from '~/reports/accessibility_report/store/mutations';
+import createStore from '~/reports/accessibility_report/store';
+
+describe('Accessibility Reports mutations', () => {
+ let localState;
+ let localStore;
+
+ beforeEach(() => {
+ localStore = createStore();
+ localState = localStore.state;
+ });
+
+ describe('SET_ENDPOINT', () => {
+ it('sets endpoint to given value', () => {
+ const endpoint = 'endpoint.json';
+ mutations.SET_ENDPOINT(localState, endpoint);
+
+ expect(localState.endpoint).toEqual(endpoint);
+ });
+ });
+
+ describe('REQUEST_REPORT', () => {
+ it('sets isLoading to true', () => {
+ mutations.REQUEST_REPORT(localState);
+
+ expect(localState.isLoading).toEqual(true);
+ });
+ });
+
+ describe('RECEIVE_REPORT_SUCCESS', () => {
+ it('sets isLoading to false', () => {
+ mutations.RECEIVE_REPORT_SUCCESS(localState, {});
+
+ expect(localState.isLoading).toEqual(false);
+ });
+
+ it('sets hasError to false', () => {
+ mutations.RECEIVE_REPORT_SUCCESS(localState, {});
+
+ expect(localState.hasError).toEqual(false);
+ });
+
+ it('sets report to response report', () => {
+ const report = { data: 'testing' };
+ mutations.RECEIVE_REPORT_SUCCESS(localState, report);
+
+ expect(localState.report).toEqual(report);
+ });
+ });
+
+ describe('RECEIVE_REPORT_ERROR', () => {
+ it('sets isLoading to false', () => {
+ mutations.RECEIVE_REPORT_ERROR(localState);
+
+ expect(localState.isLoading).toEqual(false);
+ });
+
+ it('sets hasError to true', () => {
+ mutations.RECEIVE_REPORT_ERROR(localState);
+
+ expect(localState.hasError).toEqual(true);
+ });
+ });
+});
diff --git a/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap b/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap
new file mode 100644
index 00000000000..c932379a253
--- /dev/null
+++ b/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Grouped Issues List renders a smart virtual list with the correct props 1`] = `
+Object {
+ "length": 4,
+ "remain": 20,
+ "rtag": "div",
+ "size": 32,
+ "wclass": "report-block-list",
+ "wtag": "ul",
+}
+`;
+
+exports[`Grouped Issues List with data renders a report item with the correct props 1`] = `
+Object {
+ "component": "TestIssueBody",
+ "isNew": false,
+ "issue": Object {
+ "name": "foo",
+ },
+ "showReportSectionStatusIcon": false,
+ "status": "none",
+ "statusIconSize": 24,
+}
+`;
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
new file mode 100644
index 00000000000..70e1ff01323
--- /dev/null
+++ b/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap
@@ -0,0 +1,37 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`IssueStatusIcon renders "failed" state correctly 1`] = `
+<div
+ class="report-block-list-icon failed"
+>
+ <icon-stub
+ data-qa-selector="status_failed_icon"
+ name="status_failed_borderless"
+ size="24"
+ />
+</div>
+`;
+
+exports[`IssueStatusIcon renders "neutral" state correctly 1`] = `
+<div
+ class="report-block-list-icon neutral"
+>
+ <icon-stub
+ data-qa-selector="status_neutral_icon"
+ name="dash"
+ size="24"
+ />
+</div>
+`;
+
+exports[`IssueStatusIcon renders "success" state correctly 1`] = `
+<div
+ class="report-block-list-icon success"
+>
+ <icon-stub
+ data-qa-selector="status_success_icon"
+ name="status_success_borderless"
+ size="24"
+ />
+</div>
+`;
diff --git a/spec/frontend/reports/components/grouped_issues_list_spec.js b/spec/frontend/reports/components/grouped_issues_list_spec.js
new file mode 100644
index 00000000000..1f8f4a0e4c1
--- /dev/null
+++ b/spec/frontend/reports/components/grouped_issues_list_spec.js
@@ -0,0 +1,86 @@
+import { shallowMount } from '@vue/test-utils';
+import GroupedIssuesList from '~/reports/components/grouped_issues_list.vue';
+import ReportItem from '~/reports/components/report_item.vue';
+import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
+
+describe('Grouped Issues List', () => {
+ let wrapper;
+
+ const createComponent = ({ propsData = {}, stubs = {} } = {}) => {
+ wrapper = shallowMount(GroupedIssuesList, {
+ propsData,
+ stubs,
+ });
+ };
+
+ const findHeading = groupName => wrapper.find(`[data-testid="${groupName}Heading"`);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders a smart virtual list with the correct props', () => {
+ createComponent({
+ propsData: {
+ resolvedIssues: [{ name: 'foo' }],
+ unresolvedIssues: [{ name: 'bar' }],
+ },
+ stubs: {
+ SmartVirtualList,
+ },
+ });
+
+ expect(wrapper.find(SmartVirtualList).props()).toMatchSnapshot();
+ });
+
+ describe('without data', () => {
+ beforeEach(createComponent);
+
+ it.each(['unresolved', 'resolved'])('does not a render a header for %s issues', issueName => {
+ expect(findHeading(issueName).exists()).toBe(false);
+ });
+
+ it.each('resolved', 'unresolved')('does not render report items for %s issues', () => {
+ expect(wrapper.contains(ReportItem)).toBe(false);
+ });
+ });
+
+ describe('with data', () => {
+ it.each`
+ givenIssues | givenHeading | groupName
+ ${[{ name: 'foo issue' }]} | ${'Foo Heading'} | ${'resolved'}
+ ${[{ name: 'bar issue' }]} | ${'Bar Heading'} | ${'unresolved'}
+ `('renders the heading for $groupName issues', ({ givenIssues, givenHeading, groupName }) => {
+ createComponent({
+ propsData: { [`${groupName}Issues`]: givenIssues, [`${groupName}Heading`]: givenHeading },
+ });
+
+ expect(findHeading(groupName).text()).toBe(givenHeading);
+ });
+
+ it.each(['resolved', 'unresolved'])('renders all %s issues', issueName => {
+ const issues = [{ name: 'foo' }, { name: 'bar' }];
+
+ createComponent({
+ propsData: { [`${issueName}Issues`]: issues },
+ });
+
+ expect(wrapper.findAll(ReportItem)).toHaveLength(issues.length);
+ });
+
+ it('renders a report item with the correct props', () => {
+ createComponent({
+ propsData: {
+ resolvedIssues: [{ name: 'foo' }],
+ component: 'TestIssueBody',
+ },
+ stubs: {
+ ReportItem,
+ },
+ });
+
+ expect(wrapper.find(ReportItem).props()).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/reports/components/grouped_test_reports_app_spec.js b/spec/frontend/reports/components/grouped_test_reports_app_spec.js
new file mode 100644
index 00000000000..1a01db391da
--- /dev/null
+++ b/spec/frontend/reports/components/grouped_test_reports_app_spec.js
@@ -0,0 +1,260 @@
+import Vue from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import state from '~/reports/store/state';
+import component from '~/reports/components/grouped_test_reports_app.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+import { failedReport } from '../mock_data/mock_data';
+import newFailedTestReports from '../mock_data/new_failures_report.json';
+import newErrorsTestReports from '../mock_data/new_errors_report.json';
+import successTestReports from '../mock_data/no_failures_report.json';
+import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json';
+import resolvedFailures from '../mock_data/resolved_failures.json';
+
+describe('Grouped Test Reports App', () => {
+ let vm;
+ let mock;
+ const Component = Vue.extend(component);
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ vm.$store.replaceState(state());
+ vm.$destroy();
+ mock.restore();
+ });
+
+ describe('with success result', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, successTestReports, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders success summary text', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary contained no changed test results out of 11 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain(
+ 'rspec:pg found no changed test results out of 8 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain(
+ 'java ant found no changed test results out of 3 total tests',
+ );
+ done();
+ });
+ });
+ });
+
+ describe('with 204 result', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(204, {}, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders success summary text', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.gl-spinner')).not.toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary results are being parsed',
+ );
+
+ done();
+ });
+ });
+ });
+
+ describe('with new failed result', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, newFailedTestReports, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders failed summary text + new badge', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary contained 2 failed out of 11 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain('rspec:pg found 2 failed out of 8 total tests');
+
+ expect(vm.$el.textContent).toContain('New');
+ expect(vm.$el.textContent).toContain(
+ 'java ant found no changed test results out of 3 total tests',
+ );
+ done();
+ });
+ });
+ });
+
+ describe('with new error result', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, newErrorsTestReports, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders error summary text + new badge', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary contained 2 errors out of 11 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain('karma found 2 errors out of 3 total tests');
+
+ expect(vm.$el.textContent).toContain('New');
+ expect(vm.$el.textContent).toContain(
+ 'rspec:pg found no changed test results out of 8 total tests',
+ );
+ done();
+ });
+ });
+ });
+
+ describe('with mixed results', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, mixedResultsTestReports, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders summary text', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary contained 2 failed and 2 fixed test results out of 11 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain(
+ 'rspec:pg found 1 failed and 2 fixed test results out of 8 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain('New');
+ expect(vm.$el.textContent).toContain(' java ant found 1 failed out of 3 total tests');
+ done();
+ });
+ });
+ });
+
+ describe('with resolved failures and resolved errors', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, resolvedFailures, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders summary text', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary contained 4 fixed test results out of 11 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain(
+ 'rspec:pg found 4 fixed test results out of 8 total tests',
+ );
+ done();
+ });
+ });
+
+ it('renders resolved failures', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
+ resolvedFailures.suites[0].resolved_failures[0].name,
+ );
+
+ expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
+ resolvedFailures.suites[0].resolved_failures[1].name,
+ );
+ done();
+ });
+ });
+
+ it('renders resolved errors', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
+ resolvedFailures.suites[0].resolved_errors[0].name,
+ );
+
+ expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
+ resolvedFailures.suites[0].resolved_errors[1].name,
+ );
+ done();
+ });
+ });
+ });
+
+ describe('with a report that failed to load', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, failedReport, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders an error status for the report', done => {
+ setImmediate(() => {
+ const { name } = failedReport.suites[0];
+
+ expect(vm.$el.querySelector('.report-block-list-issue').textContent).toContain(
+ `An error occurred while loading ${name} results`,
+ );
+ done();
+ });
+ });
+ });
+
+ describe('with error', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(500, {}, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders loading summary text with loading icon', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary failed loading results',
+ );
+ done();
+ });
+ });
+ });
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, {}, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders loading summary text with loading icon', done => {
+ expect(vm.$el.querySelector('.gl-spinner')).not.toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary results are being parsed',
+ );
+
+ setImmediate(() => {
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/reports/components/issue_status_icon_spec.js b/spec/frontend/reports/components/issue_status_icon_spec.js
new file mode 100644
index 00000000000..3a55ff0a9e3
--- /dev/null
+++ b/spec/frontend/reports/components/issue_status_icon_spec.js
@@ -0,0 +1,29 @@
+import { shallowMount } from '@vue/test-utils';
+import ReportItem from '~/reports/components/issue_status_icon.vue';
+import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants';
+
+describe('IssueStatusIcon', () => {
+ let wrapper;
+
+ const createComponent = ({ status }) => {
+ wrapper = shallowMount(ReportItem, {
+ propsData: {
+ status,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it.each([STATUS_SUCCESS, STATUS_NEUTRAL, STATUS_FAILED])(
+ 'renders "%s" state correctly',
+ status => {
+ createComponent({ status });
+
+ expect(wrapper.element).toMatchSnapshot();
+ },
+ );
+});
diff --git a/spec/frontend/reports/components/modal_open_name_spec.js b/spec/frontend/reports/components/modal_open_name_spec.js
new file mode 100644
index 00000000000..d59f3571c4b
--- /dev/null
+++ b/spec/frontend/reports/components/modal_open_name_spec.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import component from '~/reports/components/modal_open_name.vue';
+
+Vue.use(Vuex);
+
+describe('Modal open name', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ const store = new Vuex.Store({
+ actions: {
+ openModal: () => {},
+ },
+ state: {},
+ mutations: {},
+ });
+
+ beforeEach(() => {
+ vm = mountComponentWithStore(Component, {
+ store,
+ props: {
+ issue: {
+ title: 'Issue',
+ },
+ status: 'failed',
+ },
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders the issue name', () => {
+ expect(vm.$el.textContent.trim()).toEqual('Issue');
+ });
+
+ it('calls openModal actions when button is clicked', () => {
+ jest.spyOn(vm, 'openModal').mockImplementation(() => {});
+
+ vm.$el.click();
+
+ expect(vm.openModal).toHaveBeenCalled();
+ });
+});
diff --git a/spec/javascripts/reports/components/modal_spec.js b/spec/frontend/reports/components/modal_spec.js
index ff046e64b6e..ff046e64b6e 100644
--- a/spec/javascripts/reports/components/modal_spec.js
+++ b/spec/frontend/reports/components/modal_spec.js
diff --git a/spec/frontend/reports/components/summary_row_spec.js b/spec/frontend/reports/components/summary_row_spec.js
new file mode 100644
index 00000000000..cb0cc025e80
--- /dev/null
+++ b/spec/frontend/reports/components/summary_row_spec.js
@@ -0,0 +1,37 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import component from '~/reports/components/summary_row.vue';
+
+describe('Summary row', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ const props = {
+ summary: 'SAST detected 1 new vulnerability and 1 fixed vulnerability',
+ popoverOptions: {
+ title: 'Static Application Security Testing (SAST)',
+ content: '<a>Learn more about SAST</a>',
+ },
+ statusIcon: 'warning',
+ };
+
+ beforeEach(() => {
+ vm = mountComponent(Component, props);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders provided summary', () => {
+ expect(
+ vm.$el.querySelector('.report-block-list-issue-description-text').textContent.trim(),
+ ).toEqual(props.summary);
+ });
+
+ it('renders provided icon', () => {
+ expect(vm.$el.querySelector('.report-block-list-icon span').classList).toContain(
+ 'js-ci-status-icon-warning',
+ );
+ });
+});
diff --git a/spec/frontend/reports/components/test_issue_body_spec.js b/spec/frontend/reports/components/test_issue_body_spec.js
new file mode 100644
index 00000000000..ff81020a4eb
--- /dev/null
+++ b/spec/frontend/reports/components/test_issue_body_spec.js
@@ -0,0 +1,72 @@
+import Vue from 'vue';
+import component from '~/reports/components/test_issue_body.vue';
+import createStore from '~/reports/store';
+import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { trimText } from '../../helpers/text_helper';
+import { issue } from '../mock_data/mock_data';
+
+describe('Test Issue body', () => {
+ let vm;
+ const Component = Vue.extend(component);
+ const store = createStore();
+
+ const commonProps = {
+ issue,
+ status: 'failed',
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('on click', () => {
+ it('calls openModal action', () => {
+ vm = mountComponentWithStore(Component, {
+ store,
+ props: commonProps,
+ });
+
+ jest.spyOn(vm, 'openModal').mockImplementation(() => {});
+
+ vm.$el.querySelector('button').click();
+
+ expect(vm.openModal).toHaveBeenCalledWith({
+ issue: commonProps.issue,
+ });
+ });
+ });
+
+ describe('is new', () => {
+ beforeEach(() => {
+ vm = mountComponentWithStore(Component, {
+ store,
+ props: { ...commonProps, isNew: true },
+ });
+ });
+
+ it('renders issue name', () => {
+ expect(vm.$el.textContent).toContain(commonProps.issue.name);
+ });
+
+ it('renders new badge', () => {
+ expect(trimText(vm.$el.querySelector('.badge').textContent)).toEqual('New');
+ });
+ });
+
+ describe('not new', () => {
+ beforeEach(() => {
+ vm = mountComponentWithStore(Component, {
+ store,
+ props: commonProps,
+ });
+ });
+
+ it('renders issue name', () => {
+ expect(vm.$el.textContent).toContain(commonProps.issue.name);
+ });
+
+ it('does not renders new badge', () => {
+ expect(vm.$el.querySelector('.badge')).toEqual(null);
+ });
+ });
+});
diff --git a/spec/frontend/reports/mock_data/mock_data.js b/spec/frontend/reports/mock_data/mock_data.js
new file mode 100644
index 00000000000..3caaab2fd79
--- /dev/null
+++ b/spec/frontend/reports/mock_data/mock_data.js
@@ -0,0 +1,24 @@
+export const issue = {
+ result: 'failure',
+ name: 'Test#sum when a is 1 and b is 2 returns summary',
+ execution_time: 0.009411,
+ system_output:
+ "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in \u003ctop (required)\u003e'",
+};
+
+export const failedReport = {
+ summary: { total: 11, resolved: 0, errored: 2, failed: 0 },
+ suites: [
+ {
+ name: 'rspec:pg',
+ status: 'error',
+ summary: { total: 0, resolved: 0, errored: 0, failed: 0 },
+ new_failures: [],
+ resolved_failures: [],
+ existing_failures: [],
+ new_errors: [],
+ resolved_errors: [],
+ existing_errors: [],
+ },
+ ],
+};
diff --git a/spec/javascripts/reports/mock_data/new_and_fixed_failures_report.json b/spec/frontend/reports/mock_data/new_and_fixed_failures_report.json
index 6141e5433a6..6141e5433a6 100644
--- a/spec/javascripts/reports/mock_data/new_and_fixed_failures_report.json
+++ b/spec/frontend/reports/mock_data/new_and_fixed_failures_report.json
diff --git a/spec/javascripts/reports/mock_data/new_errors_report.json b/spec/frontend/reports/mock_data/new_errors_report.json
index cebf98fdb63..cebf98fdb63 100644
--- a/spec/javascripts/reports/mock_data/new_errors_report.json
+++ b/spec/frontend/reports/mock_data/new_errors_report.json
diff --git a/spec/javascripts/reports/mock_data/new_failures_report.json b/spec/frontend/reports/mock_data/new_failures_report.json
index 8b9c12c6271..8b9c12c6271 100644
--- a/spec/javascripts/reports/mock_data/new_failures_report.json
+++ b/spec/frontend/reports/mock_data/new_failures_report.json
diff --git a/spec/javascripts/reports/mock_data/no_failures_report.json b/spec/frontend/reports/mock_data/no_failures_report.json
index 7da9e0c6211..7da9e0c6211 100644
--- a/spec/javascripts/reports/mock_data/no_failures_report.json
+++ b/spec/frontend/reports/mock_data/no_failures_report.json
diff --git a/spec/javascripts/reports/mock_data/resolved_failures.json b/spec/frontend/reports/mock_data/resolved_failures.json
index 49de6aa840b..49de6aa840b 100644
--- a/spec/javascripts/reports/mock_data/resolved_failures.json
+++ b/spec/frontend/reports/mock_data/resolved_failures.json
diff --git a/spec/frontend/reports/store/actions_spec.js b/spec/frontend/reports/store/actions_spec.js
new file mode 100644
index 00000000000..3f189736922
--- /dev/null
+++ b/spec/frontend/reports/store/actions_spec.js
@@ -0,0 +1,171 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import { TEST_HOST } from 'helpers/test_constants';
+import axios from '~/lib/utils/axios_utils';
+import {
+ setEndpoint,
+ requestReports,
+ fetchReports,
+ stopPolling,
+ clearEtagPoll,
+ receiveReportsSuccess,
+ receiveReportsError,
+ openModal,
+ setModalData,
+} from '~/reports/store/actions';
+import state from '~/reports/store/state';
+import * as types from '~/reports/store/mutation_types';
+
+describe('Reports Store Actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('setEndpoint', () => {
+ it('should commit SET_ENDPOINT mutation', done => {
+ testAction(
+ setEndpoint,
+ 'endpoint.json',
+ mockedState,
+ [{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestReports', () => {
+ it('should commit REQUEST_REPORTS mutation', done => {
+ testAction(requestReports, null, mockedState, [{ type: types.REQUEST_REPORTS }], [], done);
+ });
+ });
+
+ describe('fetchReports', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ stopPolling();
+ clearEtagPoll();
+ });
+
+ describe('success', () => {
+ it('dispatches requestReports and receiveReportsSuccess ', done => {
+ mock
+ .onGet(`${TEST_HOST}/endpoint.json`)
+ .replyOnce(200, { summary: {}, suites: [{ name: 'rspec' }] });
+
+ testAction(
+ fetchReports,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestReports',
+ },
+ {
+ payload: { data: { summary: {}, suites: [{ name: 'rspec' }] }, status: 200 },
+ type: 'receiveReportsSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
+ });
+
+ it('dispatches requestReports and receiveReportsError ', done => {
+ testAction(
+ fetchReports,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestReports',
+ },
+ {
+ type: 'receiveReportsError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('receiveReportsSuccess', () => {
+ it('should commit RECEIVE_REPORTS_SUCCESS mutation with 200', done => {
+ testAction(
+ receiveReportsSuccess,
+ { data: { summary: {} }, status: 200 },
+ mockedState,
+ [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: { summary: {} } }],
+ [],
+ done,
+ );
+ });
+
+ it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', done => {
+ testAction(
+ receiveReportsSuccess,
+ { data: { summary: {} }, status: 204 },
+ mockedState,
+ [],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveReportsError', () => {
+ it('should commit RECEIVE_REPORTS_ERROR mutation', done => {
+ testAction(
+ receiveReportsError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_REPORTS_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('openModal', () => {
+ it('should dispatch setModalData', done => {
+ testAction(
+ openModal,
+ { name: 'foo' },
+ mockedState,
+ [],
+ [{ type: 'setModalData', payload: { name: 'foo' } }],
+ done,
+ );
+ });
+ });
+
+ describe('setModalData', () => {
+ it('should commit SET_ISSUE_MODAL_DATA', done => {
+ testAction(
+ setModalData,
+ { name: 'foo' },
+ mockedState,
+ [{ type: types.SET_ISSUE_MODAL_DATA, payload: { name: 'foo' } }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/javascripts/reports/store/mutations_spec.js b/spec/frontend/reports/store/mutations_spec.js
index 9446cd454ab..9446cd454ab 100644
--- a/spec/javascripts/reports/store/mutations_spec.js
+++ b/spec/frontend/reports/store/mutations_spec.js
diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
index 491fc20c40e..1dca65dd862 100644
--- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
+++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
@@ -26,9 +26,7 @@ exports[`Repository last commit component renders commit widget 1`] = `
class="commit-row-message item-title"
href="https://test.com/commit/123"
>
-
- Commit title
-
+ Commit title
</gl-link-stub>
<!---->
@@ -128,9 +126,7 @@ exports[`Repository last commit component renders the signature HTML as returned
class="commit-row-message item-title"
href="https://test.com/commit/123"
>
-
- Commit title
-
+ Commit title
</gl-link-stub>
<!---->
diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js
index d2576ec26b7..a5bfeb08fe4 100644
--- a/spec/frontend/repository/components/last_commit_spec.js
+++ b/spec/frontend/repository/components/last_commit_spec.js
@@ -9,6 +9,7 @@ function createCommitData(data = {}) {
const defaultData = {
sha: '123456789',
title: 'Commit title',
+ titleHtml: 'Commit title',
message: 'Commit message',
webUrl: 'https://test.com/commit/123',
authoredDate: '2019-01-01',
diff --git a/spec/frontend/repository/utils/commit_spec.js b/spec/frontend/repository/utils/commit_spec.js
index e7cc28178bf..aaaa39f739f 100644
--- a/spec/frontend/repository/utils/commit_spec.js
+++ b/spec/frontend/repository/utils/commit_spec.js
@@ -8,6 +8,7 @@ const mockData = [
committed_date: '2019-01-01',
},
commit_path: `https://test.com`,
+ commit_title_html: 'testing message',
file_name: 'index.js',
type: 'blob',
},
@@ -24,6 +25,7 @@ describe('normalizeData', () => {
fileName: 'index.js',
filePath: '/index.js',
type: 'blob',
+ titleHtml: 'testing message',
__typename: 'LogTreeCommit',
},
]);
diff --git a/spec/javascripts/settings_panels_spec.js b/spec/frontend/settings_panels_spec.js
index 2c5d91a45bc..2c5d91a45bc 100644
--- a/spec/javascripts/settings_panels_spec.js
+++ b/spec/frontend/settings_panels_spec.js
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 1f93336e755..cf7832f3948 100644
--- a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap
+++ b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Confidential Issue Sidebar Block renders for isConfidential = false and isEditable = false 1`] = `
+exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = false 1`] = `
<div
class="block issuable-sidebar-item confidentiality"
>
@@ -52,7 +52,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = false and
</div>
`;
-exports[`Confidential Issue Sidebar Block renders for isConfidential = false and isEditable = true 1`] = `
+exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = true 1`] = `
<div
class="block issuable-sidebar-item confidentiality"
>
@@ -84,9 +84,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = false and
data-track-property="confidentiality"
href="#"
>
-
Edit
-
</a>
</div>
@@ -114,7 +112,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = false and
</div>
`;
-exports[`Confidential Issue Sidebar Block renders for isConfidential = true and isEditable = false 1`] = `
+exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = false 1`] = `
<div
class="block issuable-sidebar-item confidentiality"
>
@@ -166,7 +164,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = true and
</div>
`;
-exports[`Confidential Issue Sidebar Block renders for isConfidential = true and isEditable = true 1`] = `
+exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = true 1`] = `
<div
class="block issuable-sidebar-item confidentiality"
>
@@ -198,9 +196,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = true and
data-track-property="confidentiality"
href="#"
>
-
Edit
-
</a>
</div>
diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/assignees_realtime_spec.js
new file mode 100644
index 00000000000..1c62c52dc67
--- /dev/null
+++ b/spec/frontend/sidebar/assignees_realtime_spec.js
@@ -0,0 +1,102 @@
+import { shallowMount } from '@vue/test-utils';
+import ActionCable from '@rails/actioncable';
+import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
+import Mock from './mock_data';
+import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql';
+
+jest.mock('@rails/actioncable', () => {
+ const mockConsumer = {
+ subscriptions: { create: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }) },
+ };
+ return {
+ createConsumer: jest.fn().mockReturnValue(mockConsumer),
+ };
+});
+
+describe('Assignees Realtime', () => {
+ let wrapper;
+ let mediator;
+
+ const createComponent = () => {
+ wrapper = shallowMount(AssigneesRealtime, {
+ propsData: {
+ issuableIid: '1',
+ mediator,
+ projectPath: 'path/to/project',
+ },
+ mocks: {
+ $apollo: {
+ query,
+ queries: {
+ project: {
+ refetch: jest.fn(),
+ },
+ },
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mediator = new SidebarMediator(Mock.mediator);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ SidebarMediator.singleton = null;
+ });
+
+ describe('when handleFetchResult is called from smart query', () => {
+ it('sets assignees to the store', () => {
+ const data = {
+ project: {
+ issue: {
+ assignees: {
+ nodes: [{ id: 'gid://gitlab/Environments/123', avatarUrl: 'url' }],
+ },
+ },
+ },
+ };
+ const expected = [{ id: 123, avatar_url: 'url', avatarUrl: 'url' }];
+ createComponent();
+
+ wrapper.vm.handleFetchResult({ data });
+
+ expect(mediator.store.assignees).toEqual(expected);
+ });
+ });
+
+ describe('when mounted', () => {
+ it('calls create subscription', () => {
+ const cable = ActionCable.createConsumer();
+
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(cable.subscriptions.create).toHaveBeenCalledTimes(1);
+ expect(cable.subscriptions.create).toHaveBeenCalledWith(
+ {
+ channel: 'IssuesChannel',
+ iid: wrapper.props('issuableIid'),
+ project_path: wrapper.props('projectPath'),
+ },
+ { received: wrapper.vm.received },
+ );
+ });
+ });
+ });
+
+ describe('when subscription is recieved', () => {
+ it('refetches the GraphQL project query', () => {
+ createComponent();
+
+ wrapper.vm.received({ event: 'updated' });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.$apollo.queries.project.refetch).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js
new file mode 100644
index 00000000000..1f028f74423
--- /dev/null
+++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js
@@ -0,0 +1,279 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
+
+describe('Issuable Time Tracker', () => {
+ let initialData;
+ let vm;
+
+ const initTimeTrackingComponent = ({
+ timeEstimate,
+ timeSpent,
+ timeEstimateHumanReadable,
+ timeSpentHumanReadable,
+ limitToHours,
+ }) => {
+ setFixtures(`
+ <div>
+ <div id="mock-container"></div>
+ </div>
+ `);
+
+ initialData = {
+ timeEstimate,
+ timeSpent,
+ humanTimeEstimate: timeEstimateHumanReadable,
+ humanTimeSpent: timeSpentHumanReadable,
+ limitToHours: Boolean(limitToHours),
+ rootPath: '/',
+ };
+
+ const TimeTrackingComponent = Vue.extend({
+ ...TimeTracker,
+ components: {
+ ...TimeTracker.components,
+ transition: {
+ // disable animations
+ render(h) {
+ return h('div', this.$slots.default);
+ },
+ },
+ },
+ });
+ vm = mountComponent(TimeTrackingComponent, initialData, '#mock-container');
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('Initialization', () => {
+ beforeEach(() => {
+ initTimeTrackingComponent({
+ timeEstimate: 10000, // 2h 46m
+ timeSpent: 5000, // 1h 23m
+ timeEstimateHumanReadable: '2h 46m',
+ timeSpentHumanReadable: '1h 23m',
+ });
+ });
+
+ it('should return something defined', () => {
+ expect(vm).toBeDefined();
+ });
+
+ it('should correctly set timeEstimate', done => {
+ Vue.nextTick(() => {
+ expect(vm.timeEstimate).toBe(initialData.timeEstimate);
+ done();
+ });
+ });
+
+ it('should correctly set time_spent', done => {
+ Vue.nextTick(() => {
+ expect(vm.timeSpent).toBe(initialData.timeSpent);
+ done();
+ });
+ });
+ });
+
+ describe('Content Display', () => {
+ describe('Panes', () => {
+ describe('Comparison pane', () => {
+ beforeEach(() => {
+ initTimeTrackingComponent({
+ timeEstimate: 100000, // 1d 3h
+ timeSpent: 5000, // 1h 23m
+ timeEstimateHumanReadable: '1d 3h',
+ timeSpentHumanReadable: '1h 23m',
+ });
+ });
+
+ it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', done => {
+ Vue.nextTick(() => {
+ expect(vm.showComparisonState).toBe(true);
+ const $comparisonPane = vm.$el.querySelector('.time-tracking-comparison-pane');
+
+ expect($comparisonPane).toBeVisible();
+ done();
+ });
+ });
+
+ it('should show full times when the sidebar is collapsed', done => {
+ Vue.nextTick(() => {
+ const timeTrackingText = vm.$el.querySelector('.time-tracking-collapsed-summary span')
+ .textContent;
+
+ expect(timeTrackingText.trim()).toBe('1h 23m / 1d 3h');
+ done();
+ });
+ });
+
+ describe('Remaining meter', () => {
+ it('should display the remaining meter with the correct width', done => {
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.time-tracking-comparison-pane .progress[value="5"]'),
+ ).not.toBeNull();
+ done();
+ });
+ });
+
+ it('should display the remaining meter with the correct background color when within estimate', done => {
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="primary"]'),
+ ).not.toBeNull();
+ done();
+ });
+ });
+
+ it('should display the remaining meter with the correct background color when over estimate', done => {
+ vm.timeEstimate = 10000; // 2h 46m
+ vm.timeSpent = 20000000; // 231 days
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="danger"]'),
+ ).not.toBeNull();
+ done();
+ });
+ });
+ });
+ });
+
+ describe('Comparison pane when limitToHours is true', () => {
+ beforeEach(() => {
+ initTimeTrackingComponent({
+ timeEstimate: 100000, // 1d 3h
+ timeSpent: 5000, // 1h 23m
+ timeEstimateHumanReadable: '',
+ timeSpentHumanReadable: '',
+ limitToHours: true,
+ });
+ });
+
+ it('should show the correct tooltip text', done => {
+ Vue.nextTick(() => {
+ expect(vm.showComparisonState).toBe(true);
+ const $title = vm.$el.querySelector('.time-tracking-content .compare-meter').dataset
+ .originalTitle;
+
+ expect($title).toBe('Time remaining: 26h 23m');
+ done();
+ });
+ });
+ });
+
+ describe('Estimate only pane', () => {
+ beforeEach(() => {
+ initTimeTrackingComponent({
+ timeEstimate: 10000, // 2h 46m
+ timeSpent: 0,
+ timeEstimateHumanReadable: '2h 46m',
+ timeSpentHumanReadable: '',
+ });
+ });
+
+ it('should display the human readable version of time estimated', done => {
+ Vue.nextTick(() => {
+ const estimateText = vm.$el.querySelector('.time-tracking-estimate-only-pane')
+ .textContent;
+ const correctText = 'Estimated: 2h 46m';
+
+ expect(estimateText.trim()).toBe(correctText);
+ done();
+ });
+ });
+ });
+
+ describe('Spent only pane', () => {
+ beforeEach(() => {
+ initTimeTrackingComponent({
+ timeEstimate: 0,
+ timeSpent: 5000, // 1h 23m
+ timeEstimateHumanReadable: '2h 46m',
+ timeSpentHumanReadable: '1h 23m',
+ });
+ });
+
+ it('should display the human readable version of time spent', done => {
+ Vue.nextTick(() => {
+ const spentText = vm.$el.querySelector('.time-tracking-spend-only-pane').textContent;
+ const correctText = 'Spent: 1h 23m';
+
+ expect(spentText).toBe(correctText);
+ done();
+ });
+ });
+ });
+
+ describe('No time tracking pane', () => {
+ beforeEach(() => {
+ initTimeTrackingComponent({
+ timeEstimate: 0,
+ timeSpent: 0,
+ timeEstimateHumanReadable: '',
+ timeSpentHumanReadable: '',
+ });
+ });
+
+ it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', done => {
+ Vue.nextTick(() => {
+ const $noTrackingPane = vm.$el.querySelector('.time-tracking-no-tracking-pane');
+ const noTrackingText = $noTrackingPane.textContent;
+ const correctText = 'No estimate or time spent';
+
+ expect(vm.showNoTimeTrackingState).toBe(true);
+ expect($noTrackingPane).toBeVisible();
+ expect(noTrackingText.trim()).toBe(correctText);
+ done();
+ });
+ });
+ });
+
+ describe('Help pane', () => {
+ const helpButton = () => vm.$el.querySelector('.help-button');
+ const closeHelpButton = () => vm.$el.querySelector('.close-help-button');
+ const helpPane = () => vm.$el.querySelector('.time-tracking-help-state');
+
+ beforeEach(() => {
+ initTimeTrackingComponent({ timeEstimate: 0, timeSpent: 0 });
+
+ return vm.$nextTick();
+ });
+
+ it('should not show the "Help" pane by default', () => {
+ expect(vm.showHelpState).toBe(false);
+ expect(helpPane()).toBeNull();
+ });
+
+ it('should show the "Help" pane when help button is clicked', () => {
+ helpButton().click();
+
+ return vm.$nextTick().then(() => {
+ expect(vm.showHelpState).toBe(true);
+
+ // let animations run
+ jest.advanceTimersByTime(500);
+
+ expect(helpPane()).toBeVisible();
+ });
+ });
+
+ it('should not show the "Help" pane when help button is clicked and then closed', done => {
+ helpButton().click();
+
+ Vue.nextTick()
+ .then(() => closeHelpButton().click())
+ .then(() => Vue.nextTick())
+ .then(() => {
+ expect(vm.showHelpState).toBe(false);
+ expect(helpPane()).toBeNull();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js
new file mode 100644
index 00000000000..acdfb5139bf
--- /dev/null
+++ b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js
@@ -0,0 +1,41 @@
+import { shallowMount } from '@vue/test-utils';
+import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue';
+
+describe('Edit Form Buttons', () => {
+ let wrapper;
+ const findConfidentialToggle = () => wrapper.find('[data-testid="confidential-toggle"]');
+
+ const createComponent = props => {
+ wrapper = shallowMount(EditFormButtons, {
+ propsData: {
+ updateConfidentialAttribute: () => {},
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when not confidential', () => {
+ it('renders Turn On in the ', () => {
+ createComponent({
+ isConfidential: false,
+ });
+
+ expect(findConfidentialToggle().text()).toBe('Turn On');
+ });
+ });
+
+ describe('when confidential', () => {
+ it('renders on or off text based on confidentiality', () => {
+ createComponent({
+ isConfidential: true,
+ });
+
+ expect(findConfidentialToggle().text()).toBe('Turn Off');
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/confidential/edit_form_spec.js b/spec/frontend/sidebar/confidential/edit_form_spec.js
new file mode 100644
index 00000000000..137019a1e1b
--- /dev/null
+++ b/spec/frontend/sidebar/confidential/edit_form_spec.js
@@ -0,0 +1,45 @@
+import { shallowMount } from '@vue/test-utils';
+import EditForm from '~/sidebar/components/confidential/edit_form.vue';
+
+describe('Edit Form Dropdown', () => {
+ let wrapper;
+ const toggleForm = () => {};
+ const updateConfidentialAttribute = () => {};
+
+ const createComponent = props => {
+ wrapper = shallowMount(EditForm, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when not confidential', () => {
+ it('renders "You are going to turn off the confidentiality." in the ', () => {
+ createComponent({
+ isConfidential: false,
+ toggleForm,
+ updateConfidentialAttribute,
+ });
+
+ expect(wrapper.find('p').text()).toContain('You are going to turn on the confidentiality.');
+ });
+ });
+
+ describe('when confidential', () => {
+ it('renders on or off text based on confidentiality', () => {
+ createComponent({
+ isConfidential: true,
+ toggleForm,
+ updateConfidentialAttribute,
+ });
+
+ expect(wrapper.find('p').text()).toContain('You are going to turn off the confidentiality.');
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/confidential_edit_buttons_spec.js b/spec/frontend/sidebar/confidential_edit_buttons_spec.js
deleted file mode 100644
index 32da9f83112..00000000000
--- a/spec/frontend/sidebar/confidential_edit_buttons_spec.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import Vue from 'vue';
-import editFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue';
-
-describe('Edit Form Buttons', () => {
- let vm1;
- let vm2;
-
- beforeEach(() => {
- const Component = Vue.extend(editFormButtons);
- const toggleForm = () => {};
- const updateConfidentialAttribute = () => {};
-
- vm1 = new Component({
- propsData: {
- isConfidential: true,
- toggleForm,
- updateConfidentialAttribute,
- },
- }).$mount();
-
- vm2 = new Component({
- propsData: {
- isConfidential: false,
- toggleForm,
- updateConfidentialAttribute,
- },
- }).$mount();
- });
-
- it('renders on or off text based on confidentiality', () => {
- expect(vm1.$el.innerHTML.includes('Turn Off')).toBe(true);
-
- expect(vm2.$el.innerHTML.includes('Turn On')).toBe(true);
- });
-});
diff --git a/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js
deleted file mode 100644
index 369088cb258..00000000000
--- a/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import Vue from 'vue';
-import editForm from '~/sidebar/components/confidential/edit_form.vue';
-
-describe('Edit Form Dropdown', () => {
- let vm1;
- let vm2;
-
- beforeEach(() => {
- const Component = Vue.extend(editForm);
- const toggleForm = () => {};
- const updateConfidentialAttribute = () => {};
-
- vm1 = new Component({
- propsData: {
- isConfidential: true,
- toggleForm,
- updateConfidentialAttribute,
- },
- }).$mount();
-
- vm2 = new Component({
- propsData: {
- isConfidential: false,
- toggleForm,
- updateConfidentialAttribute,
- },
- }).$mount();
- });
-
- it('renders on the appropriate warning text', () => {
- expect(vm1.$el.innerHTML.includes('You are going to turn off the confidentiality.')).toBe(true);
-
- expect(vm2.$el.innerHTML.includes('You are going to turn on the confidentiality.')).toBe(true);
- });
-});
diff --git a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js
index 4853d9795b1..e7a64ec5ed9 100644
--- a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js
+++ b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js
@@ -5,6 +5,7 @@ import EditForm from '~/sidebar/components/confidential/edit_form.vue';
import SidebarService from '~/sidebar/services/sidebar_service';
import createFlash from '~/flash';
import RecaptchaModal from '~/vue_shared/components/recaptcha_modal.vue';
+import createStore from '~/notes/stores';
jest.mock('~/flash');
jest.mock('~/sidebar/services/sidebar_service');
@@ -31,8 +32,10 @@ describe('Confidential Issue Sidebar Block', () => {
};
const createComponent = propsData => {
+ const store = createStore();
const service = new SidebarService();
wrapper = shallowMount(ConfidentialIssueSidebar, {
+ store,
propsData: {
service,
...propsData,
@@ -49,29 +52,31 @@ describe('Confidential Issue Sidebar Block', () => {
});
it.each`
- isConfidential | isEditable
- ${false} | ${false}
- ${false} | ${true}
- ${true} | ${false}
- ${true} | ${true}
+ confidential | isEditable
+ ${false} | ${false}
+ ${false} | ${true}
+ ${true} | ${false}
+ ${true} | ${true}
`(
- 'renders for isConfidential = $isConfidential and isEditable = $isEditable',
- ({ isConfidential, isEditable }) => {
+ 'renders for confidential = $confidential and isEditable = $isEditable',
+ ({ confidential, isEditable }) => {
createComponent({
- isConfidential,
isEditable,
});
+ wrapper.vm.$store.state.noteableData.confidential = confidential;
- expect(wrapper.element).toMatchSnapshot();
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
},
);
describe('if editable', () => {
beforeEach(() => {
createComponent({
- isConfidential: true,
isEditable: true,
});
+ wrapper.vm.$store.state.noteableData.confidential = true;
});
it('displays the edit form when editable', () => {
diff --git a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js
new file mode 100644
index 00000000000..66f9237ce97
--- /dev/null
+++ b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js
@@ -0,0 +1,31 @@
+import { shallowMount } from '@vue/test-utils';
+import EditFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue';
+
+describe('EditFormButtons', () => {
+ let wrapper;
+
+ const mountComponent = propsData => shallowMount(EditFormButtons, { propsData });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('displays "Unlock" when locked', () => {
+ wrapper = mountComponent({
+ isLocked: true,
+ updateLockedAttribute: () => {},
+ });
+
+ expect(wrapper.text()).toContain('Unlock');
+ });
+
+ it('displays "Lock" when unlocked', () => {
+ wrapper = mountComponent({
+ isLocked: false,
+ updateLockedAttribute: () => {},
+ });
+
+ expect(wrapper.text()).toContain('Lock');
+ });
+});
diff --git a/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js b/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js
new file mode 100644
index 00000000000..00997326d87
--- /dev/null
+++ b/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js
@@ -0,0 +1,99 @@
+import Vue from 'vue';
+import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
+import lockIssueSidebar from '~/sidebar/components/lock/lock_issue_sidebar.vue';
+
+describe('LockIssueSidebar', () => {
+ let vm1;
+ let vm2;
+
+ beforeEach(() => {
+ const Component = Vue.extend(lockIssueSidebar);
+
+ const mediator = {
+ service: {
+ update: Promise.resolve(true),
+ },
+
+ store: {
+ isLockDialogOpen: false,
+ },
+ };
+
+ vm1 = new Component({
+ propsData: {
+ isLocked: true,
+ isEditable: true,
+ mediator,
+ issuableType: 'issue',
+ },
+ }).$mount();
+
+ vm2 = new Component({
+ propsData: {
+ isLocked: false,
+ isEditable: false,
+ mediator,
+ issuableType: 'merge_request',
+ },
+ }).$mount();
+ });
+
+ it('shows if locked and/or editable', () => {
+ expect(vm1.$el.innerHTML.includes('Edit')).toBe(true);
+
+ expect(vm1.$el.innerHTML.includes('Locked')).toBe(true);
+
+ expect(vm2.$el.innerHTML.includes('Unlocked')).toBe(true);
+ });
+
+ it('displays the edit form when editable', done => {
+ expect(vm1.isLockDialogOpen).toBe(false);
+
+ vm1.$el.querySelector('.lock-edit').click();
+
+ expect(vm1.isLockDialogOpen).toBe(true);
+
+ vm1.$nextTick(() => {
+ expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true);
+
+ done();
+ });
+ });
+
+ it('tracks an event when "Edit" is clicked', () => {
+ const spy = mockTracking('_category_', vm1.$el, jest.spyOn);
+ triggerEvent('.lock-edit');
+
+ expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', {
+ label: 'right_sidebar',
+ property: 'lock_issue',
+ });
+ });
+
+ it('displays the edit form when opened from collapsed state', done => {
+ expect(vm1.isLockDialogOpen).toBe(false);
+
+ vm1.$el.querySelector('.sidebar-collapsed-icon').click();
+
+ expect(vm1.isLockDialogOpen).toBe(true);
+
+ setImmediate(() => {
+ expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true);
+
+ done();
+ });
+ });
+
+ it('does not display the edit form when opened from collapsed state if not editable', done => {
+ expect(vm2.isLockDialogOpen).toBe(false);
+
+ vm2.$el.querySelector('.sidebar-collapsed-icon').click();
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm2.isLockDialogOpen).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+});
diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/participants_spec.js
new file mode 100644
index 00000000000..ebe94582588
--- /dev/null
+++ b/spec/frontend/sidebar/participants_spec.js
@@ -0,0 +1,206 @@
+import { GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Participants from '~/sidebar/components/participants/participants.vue';
+
+const PARTICIPANT = {
+ id: 1,
+ state: 'active',
+ username: 'marcene',
+ name: 'Allie Will',
+ web_url: 'foo.com',
+ avatar_url: 'gravatar.com/avatar/xxx',
+};
+
+const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPANT, id: 3 }];
+
+describe('Participants', () => {
+ let wrapper;
+
+ const getMoreParticipantsButton = () => wrapper.find('button');
+
+ const getCollapsedParticipantsCount = () => wrapper.find('[data-testid="collapsed-count"]');
+
+ const mountComponent = propsData =>
+ shallowMount(Participants, {
+ propsData,
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('collapsed sidebar state', () => {
+ it('shows loading spinner when loading', () => {
+ wrapper = mountComponent({
+ loading: true,
+ });
+
+ expect(wrapper.contains(GlLoadingIcon)).toBe(true);
+ });
+
+ it('does not show loading spinner not loading', () => {
+ wrapper = mountComponent({
+ loading: false,
+ });
+
+ expect(wrapper.contains(GlLoadingIcon)).toBe(false);
+ });
+
+ it('shows participant count when given', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ });
+
+ expect(getCollapsedParticipantsCount().text()).toBe(`${PARTICIPANT_LIST.length}`);
+ });
+
+ it('shows full participant count when there are hidden participants', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants: 1,
+ });
+
+ expect(getCollapsedParticipantsCount().text()).toBe(`${PARTICIPANT_LIST.length}`);
+ });
+ });
+
+ describe('expanded sidebar state', () => {
+ it('shows loading spinner when loading', () => {
+ wrapper = mountComponent({
+ loading: true,
+ });
+
+ expect(wrapper.contains(GlLoadingIcon)).toBe(true);
+ });
+
+ it('when only showing visible participants, shows an avatar only for each participant under the limit', () => {
+ const numberOfLessParticipants = 2;
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants,
+ });
+
+ wrapper.setData({
+ isShowingMoreParticipants: false,
+ });
+
+ return Vue.nextTick().then(() => {
+ expect(wrapper.findAll('.participants-author')).toHaveLength(numberOfLessParticipants);
+ });
+ });
+
+ it('when only showing all participants, each has an avatar', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants: 2,
+ });
+
+ wrapper.setData({
+ isShowingMoreParticipants: true,
+ });
+
+ return Vue.nextTick().then(() => {
+ expect(wrapper.findAll('.participants-author')).toHaveLength(PARTICIPANT_LIST.length);
+ });
+ });
+
+ it('does not have more participants link when they can all be shown', () => {
+ const numberOfLessParticipants = 100;
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants,
+ });
+
+ expect(PARTICIPANT_LIST.length).toBeLessThan(numberOfLessParticipants);
+ expect(getMoreParticipantsButton().exists()).toBe(false);
+ });
+
+ it('when too many participants, has more participants link to show more', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants: 2,
+ });
+
+ wrapper.setData({
+ isShowingMoreParticipants: false,
+ });
+
+ return Vue.nextTick().then(() => {
+ expect(getMoreParticipantsButton().text()).toBe('+ 1 more');
+ });
+ });
+
+ it('when too many participants and already showing them, has more participants link to show less', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants: 2,
+ });
+
+ wrapper.setData({
+ isShowingMoreParticipants: true,
+ });
+
+ return Vue.nextTick().then(() => {
+ expect(getMoreParticipantsButton().text()).toBe('- show less');
+ });
+ });
+
+ it('clicking more participants link emits event', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants: 2,
+ });
+
+ expect(wrapper.vm.isShowingMoreParticipants).toBe(false);
+
+ getMoreParticipantsButton().trigger('click');
+
+ expect(wrapper.vm.isShowingMoreParticipants).toBe(true);
+ });
+
+ it('clicking on participants icon emits `toggleSidebar` event', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants: 2,
+ });
+
+ const spy = jest.spyOn(wrapper.vm, '$emit');
+
+ wrapper.find('.sidebar-collapsed-icon').trigger('click');
+
+ return Vue.nextTick(() => {
+ expect(spy).toHaveBeenCalledWith('toggleSidebar');
+
+ spy.mockRestore();
+ });
+ });
+ });
+
+ describe('when not showing participants label', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ participants: PARTICIPANT_LIST,
+ showParticipantLabel: false,
+ });
+ });
+
+ it('does not show sidebar collapsed icon', () => {
+ expect(wrapper.contains('.sidebar-collapsed-icon')).toBe(false);
+ });
+
+ it('does not show participants label title', () => {
+ expect(wrapper.contains('.title')).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/sidebar_assignees_spec.js b/spec/frontend/sidebar/sidebar_assignees_spec.js
index c1876066a21..88e2d2c9514 100644
--- a/spec/frontend/sidebar/sidebar_assignees_spec.js
+++ b/spec/frontend/sidebar/sidebar_assignees_spec.js
@@ -3,6 +3,7 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import axios from 'axios';
import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees.vue';
import Assigness from '~/sidebar/components/assignees/assignees.vue';
+import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarStore from '~/sidebar/stores/sidebar_store';
@@ -12,12 +13,19 @@ describe('sidebar assignees', () => {
let wrapper;
let mediator;
let axiosMock;
-
- const createComponent = () => {
+ const createComponent = (realTimeIssueSidebar = false, props) => {
wrapper = shallowMount(SidebarAssignees, {
propsData: {
+ issuableIid: '1',
mediator,
field: '',
+ projectPath: 'projectPath',
+ ...props,
+ },
+ provide: {
+ glFeatures: {
+ realTimeIssueSidebar,
+ },
},
// Attaching to document is required because this component emits something from the parent element :/
attachToDocument: true,
@@ -30,8 +38,6 @@ describe('sidebar assignees', () => {
jest.spyOn(mediator, 'saveAssignees');
jest.spyOn(mediator, 'assignYourself');
-
- createComponent();
});
afterEach(() => {
@@ -45,6 +51,8 @@ describe('sidebar assignees', () => {
});
it('calls the mediator when saves the assignees', () => {
+ createComponent();
+
expect(mediator.saveAssignees).not.toHaveBeenCalled();
wrapper.vm.saveAssignees();
@@ -53,6 +61,8 @@ describe('sidebar assignees', () => {
});
it('calls the mediator when "assignSelf" method is called', () => {
+ createComponent();
+
expect(mediator.assignYourself).not.toHaveBeenCalled();
expect(mediator.store.assignees.length).toBe(0);
@@ -63,6 +73,8 @@ describe('sidebar assignees', () => {
});
it('hides assignees until fetched', () => {
+ createComponent();
+
expect(wrapper.find(Assigness).exists()).toBe(false);
wrapper.vm.store.isFetching.assignees = false;
@@ -71,4 +83,30 @@ describe('sidebar assignees', () => {
expect(wrapper.find(Assigness).exists()).toBe(true);
});
});
+
+ describe('when realTimeIssueSidebar is turned on', () => {
+ describe('when issuableType is issue', () => {
+ it('finds AssigneesRealtime componeont', () => {
+ createComponent(true);
+
+ expect(wrapper.find(AssigneesRealtime).exists()).toBe(true);
+ });
+ });
+
+ describe('when issuableType is MR', () => {
+ it('does not find AssigneesRealtime componeont', () => {
+ createComponent(true, { issuableType: 'MR' });
+
+ expect(wrapper.find(AssigneesRealtime).exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('when realTimeIssueSidebar is turned off', () => {
+ it('does not find AssigneesRealtime', () => {
+ createComponent(false, { issuableType: 'issue' });
+
+ expect(wrapper.find(AssigneesRealtime).exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js
new file mode 100644
index 00000000000..0892d452966
--- /dev/null
+++ b/spec/frontend/sidebar/sidebar_mediator_spec.js
@@ -0,0 +1,135 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import * as urlUtility from '~/lib/utils/url_utility';
+import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
+import SidebarStore from '~/sidebar/stores/sidebar_store';
+import Mock from './mock_data';
+
+describe('Sidebar mediator', () => {
+ const { mediator: mediatorMockData } = Mock;
+ let mock;
+ let mediator;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mediator = new SidebarMediator(mediatorMockData);
+ });
+
+ afterEach(() => {
+ SidebarService.singleton = null;
+ SidebarStore.singleton = null;
+ SidebarMediator.singleton = null;
+ mock.restore();
+ });
+
+ it('assigns yourself ', () => {
+ mediator.assignYourself();
+
+ expect(mediator.store.currentUser).toEqual(mediatorMockData.currentUser);
+ expect(mediator.store.assignees[0]).toEqual(mediatorMockData.currentUser);
+ });
+
+ it('saves assignees', () => {
+ mock.onPut(mediatorMockData.endpoint).reply(200, {});
+
+ return mediator.saveAssignees('issue[assignee_ids]').then(resp => {
+ expect(resp.status).toEqual(200);
+ });
+ });
+
+ it('fetches the data', () => {
+ const mockData = Mock.responseMap.GET[mediatorMockData.endpoint];
+ mock.onGet(mediatorMockData.endpoint).reply(200, mockData);
+
+ const mockGraphQlData = Mock.graphQlResponseData;
+ const graphQlSpy = jest.spyOn(gqClient, 'query').mockReturnValue({
+ data: mockGraphQlData,
+ });
+ const spy = jest.spyOn(mediator, 'processFetchedData').mockReturnValue(Promise.resolve());
+
+ return mediator.fetch().then(() => {
+ expect(spy).toHaveBeenCalledWith(mockData, mockGraphQlData);
+
+ spy.mockRestore();
+ graphQlSpy.mockRestore();
+ });
+ });
+
+ it('processes fetched data', () => {
+ const mockData = Mock.responseMap.GET[mediatorMockData.endpoint];
+ mediator.processFetchedData(mockData);
+
+ expect(mediator.store.assignees).toEqual(mockData.assignees);
+ expect(mediator.store.humanTimeEstimate).toEqual(mockData.human_time_estimate);
+ expect(mediator.store.humanTotalTimeSpent).toEqual(mockData.human_total_time_spent);
+ expect(mediator.store.participants).toEqual(mockData.participants);
+ expect(mediator.store.subscribed).toEqual(mockData.subscribed);
+ expect(mediator.store.timeEstimate).toEqual(mockData.time_estimate);
+ expect(mediator.store.totalTimeSpent).toEqual(mockData.total_time_spent);
+ });
+
+ it('sets moveToProjectId', () => {
+ const projectId = 7;
+ const spy = jest.spyOn(mediator.store, 'setMoveToProjectId').mockReturnValue(Promise.resolve());
+
+ mediator.setMoveToProjectId(projectId);
+
+ expect(spy).toHaveBeenCalledWith(projectId);
+
+ spy.mockRestore();
+ });
+
+ it('fetches autocomplete projects', () => {
+ const searchTerm = 'foo';
+ mock.onGet(mediatorMockData.projectsAutocompleteEndpoint).reply(200, {});
+ const getterSpy = jest
+ .spyOn(mediator.service, 'getProjectsAutocomplete')
+ .mockReturnValue(Promise.resolve({ data: {} }));
+ const setterSpy = jest
+ .spyOn(mediator.store, 'setAutocompleteProjects')
+ .mockReturnValue(Promise.resolve());
+
+ return mediator.fetchAutocompleteProjects(searchTerm).then(() => {
+ expect(getterSpy).toHaveBeenCalledWith(searchTerm);
+ expect(setterSpy).toHaveBeenCalled();
+
+ getterSpy.mockRestore();
+ setterSpy.mockRestore();
+ });
+ });
+
+ it('moves issue', () => {
+ const mockData = Mock.responseMap.POST[mediatorMockData.moveIssueEndpoint];
+ const moveToProjectId = 7;
+ mock.onPost(mediatorMockData.moveIssueEndpoint).reply(200, mockData);
+ mediator.store.setMoveToProjectId(moveToProjectId);
+ const moveIssueSpy = jest
+ .spyOn(mediator.service, 'moveIssue')
+ .mockReturnValue(Promise.resolve({ data: { web_url: mockData.web_url } }));
+ const urlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({});
+
+ return mediator.moveIssue().then(() => {
+ expect(moveIssueSpy).toHaveBeenCalledWith(moveToProjectId);
+ expect(urlSpy).toHaveBeenCalledWith(mockData.web_url);
+
+ moveIssueSpy.mockRestore();
+ urlSpy.mockRestore();
+ });
+ });
+
+ it('toggle subscription', () => {
+ mediator.store.setSubscribedState(false);
+ mock.onPost(mediatorMockData.toggleSubscriptionEndpoint).reply(200, {});
+ const spy = jest
+ .spyOn(mediator.service, 'toggleSubscription')
+ .mockReturnValue(Promise.resolve());
+
+ return mediator.toggleSubscription().then(() => {
+ expect(spy).toHaveBeenCalled();
+ expect(mediator.store.subscribed).toEqual(true);
+
+ spy.mockRestore();
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/sidebar_move_issue_spec.js b/spec/frontend/sidebar/sidebar_move_issue_spec.js
new file mode 100644
index 00000000000..db0d3e06272
--- /dev/null
+++ b/spec/frontend/sidebar/sidebar_move_issue_spec.js
@@ -0,0 +1,167 @@
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
+import SidebarStore from '~/sidebar/stores/sidebar_store';
+import SidebarService from '~/sidebar/services/sidebar_service';
+import SidebarMoveIssue from '~/sidebar/lib/sidebar_move_issue';
+import Mock from './mock_data';
+
+describe('SidebarMoveIssue', () => {
+ let mock;
+ const test = {};
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ const mockData = Mock.responseMap.GET['/autocomplete/projects?project_id=15'];
+ mock.onGet('/autocomplete/projects?project_id=15').reply(200, mockData);
+ test.mediator = new SidebarMediator(Mock.mediator);
+ test.$content = $(`
+ <div class="dropdown">
+ <div class="js-toggle"></div>
+ <div class="dropdown-menu">
+ <div class="dropdown-content"></div>
+ </div>
+ <div class="js-confirm-button"></div>
+ </div>
+ `);
+ test.$toggleButton = test.$content.find('.js-toggle');
+ test.$confirmButton = test.$content.find('.js-confirm-button');
+
+ test.sidebarMoveIssue = new SidebarMoveIssue(
+ test.mediator,
+ test.$toggleButton,
+ test.$confirmButton,
+ );
+ test.sidebarMoveIssue.init();
+ });
+
+ afterEach(() => {
+ SidebarService.singleton = null;
+ SidebarStore.singleton = null;
+ SidebarMediator.singleton = null;
+
+ test.sidebarMoveIssue.destroy();
+ mock.restore();
+ });
+
+ describe('init', () => {
+ it('should initialize the dropdown and listeners', () => {
+ jest.spyOn(test.sidebarMoveIssue, 'initDropdown').mockImplementation(() => {});
+ jest.spyOn(test.sidebarMoveIssue, 'addEventListeners').mockImplementation(() => {});
+
+ test.sidebarMoveIssue.init();
+
+ expect(test.sidebarMoveIssue.initDropdown).toHaveBeenCalled();
+ expect(test.sidebarMoveIssue.addEventListeners).toHaveBeenCalled();
+ });
+ });
+
+ describe('destroy', () => {
+ it('should remove the listeners', () => {
+ jest.spyOn(test.sidebarMoveIssue, 'removeEventListeners').mockImplementation(() => {});
+
+ test.sidebarMoveIssue.destroy();
+
+ expect(test.sidebarMoveIssue.removeEventListeners).toHaveBeenCalled();
+ });
+ });
+
+ describe('initDropdown', () => {
+ it('should initialize the gl_dropdown', () => {
+ jest.spyOn($.fn, 'glDropdown').mockImplementation(() => {});
+
+ test.sidebarMoveIssue.initDropdown();
+
+ expect($.fn.glDropdown).toHaveBeenCalled();
+ });
+
+ it('escapes html from project name', done => {
+ test.$toggleButton.dropdown('toggle');
+
+ setImmediate(() => {
+ expect(test.$content.find('.js-move-issue-dropdown-item')[1].innerHTML.trim()).toEqual(
+ '&lt;img src=x onerror=alert(document.domain)&gt; foo / bar',
+ );
+ done();
+ });
+ });
+ });
+
+ describe('onConfirmClicked', () => {
+ it('should move the issue with valid project ID', () => {
+ jest.spyOn(test.mediator, 'moveIssue').mockReturnValue(Promise.resolve());
+ test.mediator.setMoveToProjectId(7);
+
+ test.sidebarMoveIssue.onConfirmClicked();
+
+ expect(test.mediator.moveIssue).toHaveBeenCalled();
+ expect(test.$confirmButton.prop('disabled')).toBeTruthy();
+ expect(test.$confirmButton.hasClass('is-loading')).toBe(true);
+ });
+
+ it('should remove loading state from confirm button on failure', done => {
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+ jest.spyOn(test.mediator, 'moveIssue').mockReturnValue(Promise.reject());
+ test.mediator.setMoveToProjectId(7);
+
+ test.sidebarMoveIssue.onConfirmClicked();
+
+ expect(test.mediator.moveIssue).toHaveBeenCalled();
+ // Wait for the move issue request to fail
+ setImmediate(() => {
+ expect(window.Flash).toHaveBeenCalled();
+ expect(test.$confirmButton.prop('disabled')).toBeFalsy();
+ expect(test.$confirmButton.hasClass('is-loading')).toBe(false);
+ done();
+ });
+ });
+
+ it('should not move the issue with id=0', () => {
+ jest.spyOn(test.mediator, 'moveIssue').mockImplementation(() => {});
+ test.mediator.setMoveToProjectId(0);
+
+ test.sidebarMoveIssue.onConfirmClicked();
+
+ expect(test.mediator.moveIssue).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should set moveToProjectId on dropdown item "No project" click', done => {
+ jest.spyOn(test.mediator, 'setMoveToProjectId').mockImplementation(() => {});
+
+ // Open the dropdown
+ test.$toggleButton.dropdown('toggle');
+
+ // Wait for the autocomplete request to finish
+ setImmediate(() => {
+ test.$content
+ .find('.js-move-issue-dropdown-item')
+ .eq(0)
+ .trigger('click');
+
+ expect(test.mediator.setMoveToProjectId).toHaveBeenCalledWith(0);
+ expect(test.$confirmButton.prop('disabled')).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should set moveToProjectId on dropdown item click', done => {
+ jest.spyOn(test.mediator, 'setMoveToProjectId').mockImplementation(() => {});
+
+ // Open the dropdown
+ test.$toggleButton.dropdown('toggle');
+
+ // Wait for the autocomplete request to finish
+ setImmediate(() => {
+ test.$content
+ .find('.js-move-issue-dropdown-item')
+ .eq(1)
+ .trigger('click');
+
+ expect(test.mediator.setMoveToProjectId).toHaveBeenCalledWith(20);
+ expect(test.$confirmButton.attr('disabled')).toBe(undefined);
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/sidebar_subscriptions_spec.js b/spec/frontend/sidebar/sidebar_subscriptions_spec.js
new file mode 100644
index 00000000000..18aaeabe3dd
--- /dev/null
+++ b/spec/frontend/sidebar/sidebar_subscriptions_spec.js
@@ -0,0 +1,36 @@
+import { shallowMount } from '@vue/test-utils';
+import SidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_subscriptions.vue';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
+import SidebarService from '~/sidebar/services/sidebar_service';
+import SidebarStore from '~/sidebar/stores/sidebar_store';
+import Mock from './mock_data';
+
+describe('Sidebar Subscriptions', () => {
+ let wrapper;
+ let mediator;
+
+ beforeEach(() => {
+ mediator = new SidebarMediator(Mock.mediator);
+ wrapper = shallowMount(SidebarSubscriptions, {
+ propsData: {
+ mediator,
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ SidebarService.singleton = null;
+ SidebarStore.singleton = null;
+ SidebarMediator.singleton = null;
+ });
+
+ it('calls the mediator toggleSubscription on event', () => {
+ const spy = jest.spyOn(mediator, 'toggleSubscription').mockReturnValue(Promise.resolve());
+
+ wrapper.vm.onToggleSubscription();
+
+ expect(spy).toHaveBeenCalled();
+ spy.mockRestore();
+ });
+});
diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/subscriptions_spec.js
new file mode 100644
index 00000000000..cce35666985
--- /dev/null
+++ b/spec/frontend/sidebar/subscriptions_spec.js
@@ -0,0 +1,106 @@
+import { shallowMount } from '@vue/test-utils';
+import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
+import eventHub from '~/sidebar/event_hub';
+import ToggleButton from '~/vue_shared/components/toggle_button.vue';
+
+describe('Subscriptions', () => {
+ let wrapper;
+
+ const findToggleButton = () => wrapper.find(ToggleButton);
+
+ const mountComponent = propsData =>
+ shallowMount(Subscriptions, {
+ propsData,
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('shows loading spinner when loading', () => {
+ wrapper = mountComponent({
+ loading: true,
+ subscribed: undefined,
+ });
+
+ expect(findToggleButton().attributes('isloading')).toBe('true');
+ });
+
+ it('is toggled "off" when currently not subscribed', () => {
+ wrapper = mountComponent({
+ subscribed: false,
+ });
+
+ expect(findToggleButton().attributes('value')).toBeFalsy();
+ });
+
+ it('is toggled "on" when currently subscribed', () => {
+ wrapper = mountComponent({
+ subscribed: true,
+ });
+
+ expect(findToggleButton().attributes('value')).toBe('true');
+ });
+
+ it('toggleSubscription method emits `toggleSubscription` event on eventHub and Component', () => {
+ const id = 42;
+ wrapper = mountComponent({ subscribed: true, id });
+ const eventHubSpy = jest.spyOn(eventHub, '$emit');
+ const wrapperEmitSpy = jest.spyOn(wrapper.vm, '$emit');
+
+ wrapper.vm.toggleSubscription();
+
+ expect(eventHubSpy).toHaveBeenCalledWith('toggleSubscription', id);
+ expect(wrapperEmitSpy).toHaveBeenCalledWith('toggleSubscription', id);
+ eventHubSpy.mockRestore();
+ wrapperEmitSpy.mockRestore();
+ });
+
+ it('tracks the event when toggled', () => {
+ wrapper = mountComponent({ subscribed: true });
+
+ const wrapperTrackSpy = jest.spyOn(wrapper.vm, 'track');
+
+ wrapper.vm.toggleSubscription();
+
+ expect(wrapperTrackSpy).toHaveBeenCalledWith('toggle_button', {
+ property: 'notifications',
+ value: 0,
+ });
+ wrapperTrackSpy.mockRestore();
+ });
+
+ it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => {
+ wrapper = mountComponent({ subscribed: true });
+ const spy = jest.spyOn(wrapper.vm, '$emit');
+
+ wrapper.vm.onClickCollapsedIcon();
+
+ expect(spy).toHaveBeenCalledWith('toggleSidebar');
+ spy.mockRestore();
+ });
+
+ describe('given project emails are disabled', () => {
+ const subscribeDisabledDescription = 'Notifications have been disabled';
+
+ beforeEach(() => {
+ wrapper = mountComponent({
+ subscribed: false,
+ projectEmailsDisabled: true,
+ subscribeDisabledDescription,
+ });
+ });
+
+ it('sets the correct display text', () => {
+ expect(wrapper.find('.issuable-header-text').text()).toContain(subscribeDisabledDescription);
+ expect(wrapper.find({ ref: 'tooltip' }).attributes('data-original-title')).toBe(
+ subscribeDisabledDescription,
+ );
+ });
+
+ it('does not render the toggle button', () => {
+ expect(wrapper.contains('.js-issuable-subscribe-button')).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/smart_interval_spec.js b/spec/frontend/smart_interval_spec.js
index b32ac99e4e4..1a2fd7ff8f1 100644
--- a/spec/frontend/smart_interval_spec.js
+++ b/spec/frontend/smart_interval_spec.js
@@ -3,8 +3,6 @@ import { assignIn } from 'lodash';
import waitForPromises from 'helpers/wait_for_promises';
import SmartInterval from '~/smart_interval';
-jest.useFakeTimers();
-
let interval;
describe('SmartInterval', () => {
diff --git a/spec/frontend/snippet/snippet_bundle_spec.js b/spec/frontend/snippet/snippet_bundle_spec.js
index 12d20d5cd85..38d05243c65 100644
--- a/spec/frontend/snippet/snippet_bundle_spec.js
+++ b/spec/frontend/snippet/snippet_bundle_spec.js
@@ -1,94 +1,85 @@
import Editor from '~/editor/editor_lite';
-import { initEditor } from '~/snippet/snippet_bundle';
+import initEditor from '~/snippet/snippet_bundle';
import { setHTMLFixture } from 'helpers/fixtures';
jest.mock('~/editor/editor_lite', () => jest.fn());
describe('Snippet editor', () => {
- describe('Monaco editor for Snippets', () => {
- let oldGon;
- let editorEl;
- let contentEl;
- let fileNameEl;
- let form;
-
- const mockName = 'foo.bar';
- const mockContent = 'Foo Bar';
- const updatedMockContent = 'New Foo Bar';
-
- const mockEditor = {
- createInstance: jest.fn(),
- updateModelLanguage: jest.fn(),
- getValue: jest.fn().mockReturnValueOnce(updatedMockContent),
- };
- Editor.mockImplementation(() => mockEditor);
-
- function setUpFixture(name, content) {
- setHTMLFixture(`
- <div class="snippet-form-holder">
- <form>
- <input class="js-snippet-file-name" type="text" value="${name}">
- <input class="snippet-file-content" type="hidden" value="${content}">
- <pre id="editor"></pre>
- </form>
- </div>
- `);
- }
-
- function bootstrap(name = '', content = '') {
- setUpFixture(name, content);
- editorEl = document.getElementById('editor');
- contentEl = document.querySelector('.snippet-file-content');
- fileNameEl = document.querySelector('.js-snippet-file-name');
- form = document.querySelector('.snippet-form-holder form');
-
- initEditor();
- }
-
- function createEvent(name) {
- return new Event(name, {
- view: window,
- bubbles: true,
- cancelable: true,
- });
- }
-
- beforeEach(() => {
- oldGon = window.gon;
- window.gon = { features: { monacoSnippets: true } };
- bootstrap(mockName, mockContent);
+ let editorEl;
+ let contentEl;
+ let fileNameEl;
+ let form;
+
+ const mockName = 'foo.bar';
+ const mockContent = 'Foo Bar';
+ const updatedMockContent = 'New Foo Bar';
+
+ const mockEditor = {
+ createInstance: jest.fn(),
+ updateModelLanguage: jest.fn(),
+ getValue: jest.fn().mockReturnValueOnce(updatedMockContent),
+ };
+ Editor.mockImplementation(() => mockEditor);
+
+ function setUpFixture(name, content) {
+ setHTMLFixture(`
+ <div class="snippet-form-holder">
+ <form>
+ <input class="js-snippet-file-name" type="text" value="${name}">
+ <input class="snippet-file-content" type="hidden" value="${content}">
+ <pre id="editor"></pre>
+ </form>
+ </div>
+ `);
+ }
+
+ function bootstrap(name = '', content = '') {
+ setUpFixture(name, content);
+ editorEl = document.getElementById('editor');
+ contentEl = document.querySelector('.snippet-file-content');
+ fileNameEl = document.querySelector('.js-snippet-file-name');
+ form = document.querySelector('.snippet-form-holder form');
+
+ initEditor();
+ }
+
+ function createEvent(name) {
+ return new Event(name, {
+ view: window,
+ bubbles: true,
+ cancelable: true,
});
+ }
- afterEach(() => {
- window.gon = oldGon;
- });
+ beforeEach(() => {
+ bootstrap(mockName, mockContent);
+ });
- it('correctly initializes Editor', () => {
- expect(mockEditor.createInstance).toHaveBeenCalledWith({
- el: editorEl,
- blobPath: mockName,
- blobContent: mockContent,
- });
+ it('correctly initializes Editor', () => {
+ expect(mockEditor.createInstance).toHaveBeenCalledWith({
+ el: editorEl,
+ blobPath: mockName,
+ blobContent: mockContent,
});
+ });
- it('listens to file name changes and updates syntax highlighting of code', () => {
- expect(mockEditor.updateModelLanguage).not.toHaveBeenCalled();
+ it('listens to file name changes and updates syntax highlighting of code', () => {
+ expect(mockEditor.updateModelLanguage).not.toHaveBeenCalled();
- const event = createEvent('change');
+ const event = createEvent('change');
- fileNameEl.value = updatedMockContent;
- fileNameEl.dispatchEvent(event);
+ fileNameEl.value = updatedMockContent;
+ fileNameEl.dispatchEvent(event);
- expect(mockEditor.updateModelLanguage).toHaveBeenCalledWith(updatedMockContent);
- });
+ expect(mockEditor.updateModelLanguage).toHaveBeenCalledWith(updatedMockContent);
+ });
- it('listens to form submit event and populates the hidden field with most recent version of the content', () => {
- expect(contentEl.value).toBe(mockContent);
+ it('listens to form submit event and populates the hidden field with most recent version of the content', () => {
+ expect(contentEl.value).toBe(mockContent);
- const event = createEvent('submit');
+ const event = createEvent('submit');
- form.dispatchEvent(event);
- expect(contentEl.value).toBe(updatedMockContent);
- });
+ form.dispatchEvent(event);
+ expect(contentEl.value).toBe(updatedMockContent);
});
});
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap
index b1bbe2a9710..301ec5652a9 100644
--- a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap
@@ -12,6 +12,7 @@ exports[`Snippet Blob Edit component rendering matches the snapshot 1`] = `
class="file-holder snippet"
>
<blob-header-edit-stub
+ data-qa-selector="snippet_file_name"
value="lorem.txt"
/>
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 334ceaa064f..9fd4cba5b87 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
@@ -35,8 +35,8 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
>
<textarea
aria-label="Description"
- class="note-textarea js-gfm-input js-autosize markdown-area
- qa-description-textarea"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-qa-selector="snippet_description_field"
data-supports-quick-actions="false"
dir="auto"
placeholder="Write a comment or drag your files here…"
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap
new file mode 100644
index 00000000000..9ebc4e81baf
--- /dev/null
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap
@@ -0,0 +1,16 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Snippet Description component matches the snapshot 1`] = `
+<markdown-field-view-stub
+ class="snippet-description"
+ data-qa-selector="snippet_description_field"
+>
+ <div
+ class="md js-snippet-description"
+ >
+ <h2>
+ The property of Thor
+ </h2>
+ </div>
+</markdown-field-view-stub>
+`;
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index 21a4ccf5a74..ba62a0a92ca 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -100,6 +100,7 @@ describe('Snippet Edit app', () => {
});
const findSubmitButton = () => wrapper.find('[type=submit]');
+ const findCancellButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]');
describe('rendering', () => {
it('renders loader while the query is in flight', () => {
@@ -148,6 +149,21 @@ describe('Snippet Edit app', () => {
expect(isBtnDisabled).toBe(expectation);
},
);
+
+ it.each`
+ isNew | status | expectation
+ ${true} | ${`new`} | ${`/snippets`}
+ ${false} | ${`existing`} | ${newlyEditedSnippetUrl}
+ `('sets correct href for the cancel button on a $status snippet', ({ isNew, expectation }) => {
+ createComponent({
+ data: {
+ snippet: { webUrl: newlyEditedSnippetUrl },
+ newSnippet: isNew,
+ },
+ });
+
+ expect(findCancellButton().attributes('href')).toBe(expectation);
+ });
});
describe('functionality', () => {
diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js
index 1f6038bc7f0..d06489cffa9 100644
--- a/spec/frontend/snippets/components/snippet_blob_view_spec.js
+++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js
@@ -3,6 +3,7 @@ import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
import BlobContent from '~/blob/components/blob_content.vue';
+import { BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE } from '~/blob/components/constants';
import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
import {
SNIPPET_VISIBILITY_PRIVATE,
@@ -29,6 +30,8 @@ describe('Blob Embeddable', () => {
queries: {
blobContent: {
loading: contentLoading,
+ refetch: jest.fn(),
+ skip: true,
},
},
};
@@ -84,9 +87,7 @@ describe('Blob Embeddable', () => {
});
it('sets rich viewer correctly', () => {
- const data = Object.assign({}, dataMock, {
- activeViewerType: RichViewerMock.type,
- });
+ const data = { ...dataMock, activeViewerType: RichViewerMock.type };
createComponent({}, data);
expect(wrapper.find(RichViewer).exists()).toBe(true);
});
@@ -145,4 +146,35 @@ describe('Blob Embeddable', () => {
});
});
});
+
+ describe('functionality', () => {
+ describe('render error', () => {
+ const findContentEl = () => wrapper.find(BlobContent);
+
+ it('correctly sets blob on the blob-content-error component', () => {
+ createComponent();
+ expect(findContentEl().props('blob')).toEqual(BlobMock);
+ });
+
+ it(`refetches blob content on ${BLOB_RENDER_EVENT_LOAD} event`, () => {
+ createComponent();
+
+ expect(wrapper.vm.$apollo.queries.blobContent.refetch).not.toHaveBeenCalled();
+ findContentEl().vm.$emit(BLOB_RENDER_EVENT_LOAD);
+ expect(wrapper.vm.$apollo.queries.blobContent.refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it(`sets '${SimpleViewerMock.type}' as active on ${BLOB_RENDER_EVENT_SHOW_SOURCE} event`, () => {
+ createComponent(
+ {},
+ {
+ activeViewerType: RichViewerMock.type,
+ },
+ );
+
+ findContentEl().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE);
+ expect(wrapper.vm.activeViewerType).toEqual(SimpleViewerMock.type);
+ });
+ });
+ });
});
diff --git a/spec/frontend/snippets/components/snippet_description_view_spec.js b/spec/frontend/snippets/components/snippet_description_view_spec.js
new file mode 100644
index 00000000000..46467ef311e
--- /dev/null
+++ b/spec/frontend/snippets/components/snippet_description_view_spec.js
@@ -0,0 +1,27 @@
+import SnippetDescription from '~/snippets/components/snippet_description_view.vue';
+import { shallowMount } from '@vue/test-utils';
+
+describe('Snippet Description component', () => {
+ let wrapper;
+ const description = '<h2>The property of Thor</h2>';
+
+ function createComponent() {
+ wrapper = shallowMount(SnippetDescription, {
+ propsData: {
+ description,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('matches the snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js
index 16a66c70d6a..5230910b6f5 100644
--- a/spec/frontend/snippets/components/snippet_header_spec.js
+++ b/spec/frontend/snippets/components/snippet_header_spec.js
@@ -7,26 +7,27 @@ import { shallowMount } from '@vue/test-utils';
describe('Snippet header component', () => {
let wrapper;
const snippet = {
- snippet: {
- id: 'gid://gitlab/PersonalSnippet/50',
- title: 'The property of Thor',
- visibilityLevel: 'private',
- webUrl: 'http://personal.dev.null/42',
- userPermissions: {
- adminSnippet: true,
- updateSnippet: true,
- reportSnippet: false,
- },
- project: null,
- author: {
- name: 'Thor Odinson',
- },
+ id: 'gid://gitlab/PersonalSnippet/50',
+ title: 'The property of Thor',
+ visibilityLevel: 'private',
+ webUrl: 'http://personal.dev.null/42',
+ userPermissions: {
+ adminSnippet: true,
+ updateSnippet: true,
+ reportSnippet: false,
+ },
+ project: null,
+ author: {
+ name: 'Thor Odinson',
+ },
+ blob: {
+ binary: false,
},
};
const mutationVariables = {
mutation: DeleteSnippetMutation,
variables: {
- id: snippet.snippet.id,
+ id: snippet.id,
},
};
const errorMsg = 'Foo bar';
@@ -46,10 +47,12 @@ describe('Snippet header component', () => {
loading = false,
permissions = {},
mutationRes = mutationTypes.RESOLVE,
+ snippetProps = {},
} = {}) {
- const defaultProps = Object.assign({}, snippet);
+ // const defaultProps = Object.assign({}, snippet, snippetProps);
+ const defaultProps = Object.assign(snippet, snippetProps);
if (permissions) {
- Object.assign(defaultProps.snippet.userPermissions, {
+ Object.assign(defaultProps.userPermissions, {
...permissions,
});
}
@@ -65,7 +68,9 @@ describe('Snippet header component', () => {
wrapper = shallowMount(SnippetHeader, {
mocks: { $apollo },
propsData: {
- ...defaultProps,
+ snippet: {
+ ...defaultProps,
+ },
},
stubs: {
ApolloMutation,
@@ -126,6 +131,17 @@ describe('Snippet header component', () => {
expect(wrapper.find(GlModal).exists()).toBe(true);
});
+ it('renders Edit button as disabled for binary snippets', () => {
+ createComponent({
+ snippetProps: {
+ blob: {
+ binary: true,
+ },
+ },
+ });
+ expect(wrapper.find('[href*="edit"]').props('disabled')).toBe(true);
+ });
+
describe('Delete mutation', () => {
const { location } = window;
@@ -156,14 +172,34 @@ describe('Snippet header component', () => {
});
});
- it('closes modal and redirects to snippets listing in case of successful mutation', () => {
- createComponent();
- wrapper.vm.closeDeleteModal = jest.fn();
+ describe('in case of successful mutation, closes modal and redirects to correct listing', () => {
+ const createDeleteSnippet = (snippetProps = {}) => {
+ createComponent({
+ snippetProps,
+ });
+ wrapper.vm.closeDeleteModal = jest.fn();
- wrapper.vm.deleteSnippet();
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled();
- expect(window.location.pathname).toEqual('dashboard/snippets');
+ wrapper.vm.deleteSnippet();
+ return wrapper.vm.$nextTick();
+ };
+
+ it('redirects to dashboard/snippets for personal snippet', () => {
+ return createDeleteSnippet().then(() => {
+ expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled();
+ expect(window.location.pathname).toBe('dashboard/snippets');
+ });
+ });
+
+ it('redirects to project snippets for project snippet', () => {
+ const fullPath = 'foo/bar';
+ return createDeleteSnippet({
+ project: {
+ fullPath,
+ },
+ }).then(() => {
+ expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled();
+ expect(window.location.pathname).toBe(`${fullPath}/snippets`);
+ });
});
});
});
diff --git a/spec/frontend/snippets/components/snippet_title_spec.js b/spec/frontend/snippets/components/snippet_title_spec.js
index b49b2008610..88261a75f6c 100644
--- a/spec/frontend/snippets/components/snippet_title_spec.js
+++ b/spec/frontend/snippets/components/snippet_title_spec.js
@@ -1,4 +1,5 @@
import SnippetTitle from '~/snippets/components/snippet_title.vue';
+import SnippetDescription from '~/snippets/components/snippet_description_view.vue';
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
@@ -16,7 +17,7 @@ describe('Snippet header component', () => {
};
function createComponent({ props = snippet } = {}) {
- const defaultProps = Object.assign({}, props);
+ const defaultProps = { ...props };
wrapper = shallowMount(SnippetTitle, {
propsData: {
@@ -36,8 +37,9 @@ describe('Snippet header component', () => {
it('renders snippets title and description', () => {
createComponent();
+
expect(wrapper.text().trim()).toContain(title);
- expect(wrapper.find('.js-snippet-description').element.innerHTML).toBe(descriptionHtml);
+ expect(wrapper.find(SnippetDescription).props('description')).toBe(descriptionHtml);
});
it('does not render recent changes time stamp if there were no updates', () => {
diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js
new file mode 100644
index 00000000000..bfe41f65d6e
--- /dev/null
+++ b/spec/frontend/static_site_editor/components/edit_area_spec.js
@@ -0,0 +1,76 @@
+import { shallowMount } from '@vue/test-utils';
+
+import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
+
+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 { sourceContentTitle as title, sourceContent as content, returnUrl } from '../mock_data';
+
+describe('~/static_site_editor/components/edit_area.vue', () => {
+ let wrapper;
+ const savingChanges = true;
+ const newContent = `new ${content}`;
+
+ const buildWrapper = (propsData = {}) => {
+ wrapper = shallowMount(EditArea, {
+ propsData: {
+ title,
+ content,
+ returnUrl,
+ savingChanges,
+ ...propsData,
+ },
+ });
+ };
+
+ const findEditHeader = () => wrapper.find(EditHeader);
+ const findRichContentEditor = () => wrapper.find(RichContentEditor);
+ const findPublishToolbar = () => wrapper.find(PublishToolbar);
+
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders edit header', () => {
+ expect(findEditHeader().exists()).toBe(true);
+ expect(findEditHeader().props('title')).toBe(title);
+ });
+
+ it('renders rich content editor', () => {
+ expect(findRichContentEditor().exists()).toBe(true);
+ expect(findRichContentEditor().props('value')).toBe(content);
+ });
+
+ it('renders publish toolbar', () => {
+ expect(findPublishToolbar().exists()).toBe(true);
+ expect(findPublishToolbar().props('returnUrl')).toBe(returnUrl);
+ expect(findPublishToolbar().props('savingChanges')).toBe(savingChanges);
+ expect(findPublishToolbar().props('saveable')).toBe(false);
+ });
+
+ describe('when content changes', () => {
+ beforeEach(() => {
+ findRichContentEditor().vm.$emit('input', newContent);
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('sets publish toolbar as saveable when content changes', () => {
+ expect(findPublishToolbar().props('saveable')).toBe(true);
+ });
+
+ it('sets publish toolbar as not saveable when content changes are rollback', () => {
+ findRichContentEditor().vm.$emit('input', content);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findPublishToolbar().props('saveable')).toBe(false);
+ });
+ });
+ });
+});
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 82eb12d4c4d..5428ed23266 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,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue';
@@ -19,7 +19,6 @@ describe('Static Site Editor Toolbar', () => {
const findReturnUrlLink = () => wrapper.find({ ref: 'returnUrlLink' });
const findSaveChangesButton = () => wrapper.find(GlButton);
- const findLoadingIndicator = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
buildWrapper();
@@ -37,8 +36,8 @@ describe('Static Site Editor Toolbar', () => {
expect(findSaveChangesButton().attributes('disabled')).toBe('true');
});
- it('does not display saving changes indicator', () => {
- expect(findLoadingIndicator().classes()).toContain('invisible');
+ it('does not render the Submit Changes button with a loader', () => {
+ expect(findSaveChangesButton().props('loading')).toBe(false);
});
it('does not render returnUrl link', () => {
@@ -62,15 +61,11 @@ describe('Static Site Editor Toolbar', () => {
describe('when saving changes', () => {
beforeEach(() => {
- buildWrapper({ saveable: true, savingChanges: true });
+ buildWrapper({ savingChanges: true });
});
- it('disables Submit Changes button', () => {
- expect(findSaveChangesButton().attributes('disabled')).toBe('true');
- });
-
- it('displays saving changes indicator', () => {
- expect(findLoadingIndicator().classes()).not.toContain('invisible');
+ it('renders the Submit Changes button with a loading indicator', () => {
+ expect(findSaveChangesButton().props('loading')).toBe(true);
});
});
diff --git a/spec/frontend/static_site_editor/components/saved_changes_message_spec.js b/spec/frontend/static_site_editor/components/saved_changes_message_spec.js
index 659e9be59d2..a63c3a83395 100644
--- a/spec/frontend/static_site_editor/components/saved_changes_message_spec.js
+++ b/spec/frontend/static_site_editor/components/saved_changes_message_spec.js
@@ -46,14 +46,11 @@ describe('~/static_site_editor/components/saved_changes_message.vue', () => {
${'branch'} | ${findBranchLink} | ${props.branch}
${'commit'} | ${findCommitLink} | ${props.commit}
${'merge request'} | ${findMergeRequestLink} | ${props.mergeRequest}
- `('renders $desc link', ({ desc, findEl, prop }) => {
+ `('renders $desc link', ({ findEl, prop }) => {
const el = findEl();
expect(el.exists()).toBe(true);
expect(el.text()).toBe(prop.label);
-
- if (desc !== 'branch') {
- expect(el.attributes('href')).toBe(prop.url);
- }
+ expect(el.attributes('href')).toBe(prop.url);
});
});
diff --git a/spec/frontend/static_site_editor/components/static_site_editor_spec.js b/spec/frontend/static_site_editor/components/static_site_editor_spec.js
deleted file mode 100644
index 5d4e3758557..00000000000
--- a/spec/frontend/static_site_editor/components/static_site_editor_spec.js
+++ /dev/null
@@ -1,247 +0,0 @@
-import Vuex from 'vuex';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlSkeletonLoader } from '@gitlab/ui';
-
-import createState from '~/static_site_editor/store/state';
-
-import StaticSiteEditor from '~/static_site_editor/components/static_site_editor.vue';
-import EditArea from '~/static_site_editor/components/edit_area.vue';
-import EditHeader from '~/static_site_editor/components/edit_header.vue';
-import InvalidContentMessage from '~/static_site_editor/components/invalid_content_message.vue';
-import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue';
-import SubmitChangesError from '~/static_site_editor/components/submit_changes_error.vue';
-import SavedChangesMessage from '~/static_site_editor/components/saved_changes_message.vue';
-
-import {
- returnUrl,
- sourceContent,
- sourceContentTitle,
- savedContentMeta,
- submitChangesError,
-} from '../mock_data';
-
-const localVue = createLocalVue();
-
-localVue.use(Vuex);
-
-describe('StaticSiteEditor', () => {
- let wrapper;
- let store;
- let loadContentActionMock;
- let setContentActionMock;
- let submitChangesActionMock;
- let dismissSubmitChangesErrorActionMock;
-
- const buildStore = ({ initialState, getters } = {}) => {
- loadContentActionMock = jest.fn();
- setContentActionMock = jest.fn();
- submitChangesActionMock = jest.fn();
- dismissSubmitChangesErrorActionMock = jest.fn();
-
- store = new Vuex.Store({
- state: createState({
- isSupportedContent: true,
- ...initialState,
- }),
- getters: {
- contentChanged: () => false,
- ...getters,
- },
- actions: {
- loadContent: loadContentActionMock,
- setContent: setContentActionMock,
- submitChanges: submitChangesActionMock,
- dismissSubmitChangesError: dismissSubmitChangesErrorActionMock,
- },
- });
- };
- const buildContentLoadedStore = ({ initialState, getters } = {}) => {
- buildStore({
- initialState: {
- isContentLoaded: true,
- ...initialState,
- },
- getters: {
- ...getters,
- },
- });
- };
-
- const buildWrapper = () => {
- wrapper = shallowMount(StaticSiteEditor, {
- localVue,
- store,
- });
- };
-
- const findEditArea = () => wrapper.find(EditArea);
- const findEditHeader = () => wrapper.find(EditHeader);
- const findInvalidContentMessage = () => wrapper.find(InvalidContentMessage);
- const findPublishToolbar = () => wrapper.find(PublishToolbar);
- const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
- const findSubmitChangesError = () => wrapper.find(SubmitChangesError);
- const findSavedChangesMessage = () => wrapper.find(SavedChangesMessage);
-
- beforeEach(() => {
- buildStore();
- buildWrapper();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders the saved changes message when changes are submitted successfully', () => {
- buildStore({ initialState: { returnUrl, savedContentMeta } });
- buildWrapper();
-
- expect(findSavedChangesMessage().exists()).toBe(true);
- expect(findSavedChangesMessage().props()).toEqual({
- returnUrl,
- ...savedContentMeta,
- });
- });
-
- describe('when content is not loaded', () => {
- it('does not render edit area', () => {
- expect(findEditArea().exists()).toBe(false);
- });
-
- it('does not render edit header', () => {
- expect(findEditHeader().exists()).toBe(false);
- });
-
- it('does not render toolbar', () => {
- expect(findPublishToolbar().exists()).toBe(false);
- });
-
- it('does not render saved changes message', () => {
- expect(findSavedChangesMessage().exists()).toBe(false);
- });
- });
-
- describe('when content is loaded', () => {
- const content = sourceContent;
- const title = sourceContentTitle;
-
- beforeEach(() => {
- buildContentLoadedStore({ initialState: { content, title } });
- buildWrapper();
- });
-
- it('renders the edit area', () => {
- expect(findEditArea().exists()).toBe(true);
- });
-
- it('renders the edit header', () => {
- expect(findEditHeader().exists()).toBe(true);
- });
-
- it('does not render skeleton loader', () => {
- expect(findSkeletonLoader().exists()).toBe(false);
- });
-
- it('passes page content to edit area', () => {
- expect(findEditArea().props('value')).toBe(content);
- });
-
- it('passes page title to edit header', () => {
- expect(findEditHeader().props('title')).toBe(title);
- });
-
- it('renders toolbar', () => {
- expect(findPublishToolbar().exists()).toBe(true);
- });
- });
-
- it('sets toolbar as saveable when content changes', () => {
- buildContentLoadedStore({
- getters: {
- contentChanged: () => true,
- },
- });
- buildWrapper();
-
- expect(findPublishToolbar().props('saveable')).toBe(true);
- });
-
- it('displays skeleton loader when loading content', () => {
- buildStore({ initialState: { isLoadingContent: true } });
- buildWrapper();
-
- expect(findSkeletonLoader().exists()).toBe(true);
- });
-
- it('does not display submit changes error when an error does not exist', () => {
- buildContentLoadedStore();
- buildWrapper();
-
- expect(findSubmitChangesError().exists()).toBe(false);
- });
-
- it('sets toolbar as saving when saving changes', () => {
- buildContentLoadedStore({
- initialState: {
- isSavingChanges: true,
- },
- });
- buildWrapper();
-
- expect(findPublishToolbar().props('savingChanges')).toBe(true);
- });
-
- it('displays invalid content message when content is not supported', () => {
- buildStore({ initialState: { isSupportedContent: false } });
- buildWrapper();
-
- expect(findInvalidContentMessage().exists()).toBe(true);
- });
-
- describe('when submitting changes fail', () => {
- beforeEach(() => {
- buildContentLoadedStore({
- initialState: {
- submitChangesError,
- },
- });
- buildWrapper();
- });
-
- it('displays submit changes error message', () => {
- expect(findSubmitChangesError().exists()).toBe(true);
- });
-
- it('dispatches submitChanges action when error message emits retry event', () => {
- findSubmitChangesError().vm.$emit('retry');
-
- expect(submitChangesActionMock).toHaveBeenCalled();
- });
-
- it('dispatches dismissSubmitChangesError action when error message emits dismiss event', () => {
- findSubmitChangesError().vm.$emit('dismiss');
-
- expect(dismissSubmitChangesErrorActionMock).toHaveBeenCalled();
- });
- });
-
- it('dispatches load content action', () => {
- expect(loadContentActionMock).toHaveBeenCalled();
- });
-
- it('dispatches setContent action when edit area emits input event', () => {
- buildContentLoadedStore();
- buildWrapper();
-
- findEditArea().vm.$emit('input', sourceContent);
-
- expect(setContentActionMock).toHaveBeenCalledWith(expect.anything(), sourceContent, undefined);
- });
-
- it('dispatches submitChanges action when toolbar emits submit event', () => {
- buildContentLoadedStore();
- buildWrapper();
- findPublishToolbar().vm.$emit('submit');
-
- expect(submitChangesActionMock).toHaveBeenCalled();
- });
-});
diff --git a/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js b/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js
new file mode 100644
index 00000000000..8504d09e0f1
--- /dev/null
+++ b/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js
@@ -0,0 +1,25 @@
+import fileResolver from '~/static_site_editor/graphql/resolvers/file';
+import loadSourceContent from '~/static_site_editor/services/load_source_content';
+
+import {
+ projectId,
+ sourcePath,
+ sourceContentTitle as title,
+ sourceContent as content,
+} from '../../mock_data';
+
+jest.mock('~/static_site_editor/services/load_source_content', () => jest.fn());
+
+describe('static_site_editor/graphql/resolvers/file', () => {
+ it('returns file content and title when fetching file successfully', () => {
+ loadSourceContent.mockResolvedValueOnce({ title, content });
+
+ return fileResolver({ fullPath: projectId }, { path: sourcePath }).then(file => {
+ expect(file).toEqual({
+ __typename: 'File',
+ title,
+ content,
+ });
+ });
+ });
+});
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
new file mode 100644
index 00000000000..515b5394594
--- /dev/null
+++ b/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js
@@ -0,0 +1,37 @@
+import savedContentMetaQuery from '~/static_site_editor/graphql/queries/saved_content_meta.query.graphql';
+import submitContentChanges from '~/static_site_editor/services/submit_content_changes';
+import submitContentChangesResolver from '~/static_site_editor/graphql/resolvers/submit_content_changes';
+
+import {
+ projectId as project,
+ sourcePath,
+ username,
+ sourceContent as content,
+ savedContentMeta,
+} from '../../mock_data';
+
+jest.mock('~/static_site_editor/services/submit_content_changes', () => jest.fn());
+
+describe('static_site_editor/graphql/resolvers/submit_content_changes', () => {
+ it('writes savedContentMeta query with the data returned by the submitContentChanges service', () => {
+ const cache = { writeQuery: jest.fn() };
+
+ submitContentChanges.mockResolvedValueOnce(savedContentMeta);
+
+ return submitContentChangesResolver(
+ {},
+ { input: { path: sourcePath, project, sourcePath, content, username } },
+ { cache },
+ ).then(() => {
+ expect(cache.writeQuery).toHaveBeenCalledWith({
+ query: savedContentMetaQuery,
+ data: {
+ savedContentMeta: {
+ __typename: 'SavedContentMeta',
+ ...savedContentMeta,
+ },
+ },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js
index 962047e6dd2..371695e913e 100644
--- a/spec/frontend/static_site_editor/mock_data.js
+++ b/spec/frontend/static_site_editor/mock_data.js
@@ -34,6 +34,9 @@ export const savedContentMeta = {
};
export const submitChangesError = 'Could not save changes';
+export const commitBranchResponse = {
+ web_url: '/tree/root-master-patch-88195',
+};
export const commitMultipleResponse = {
short_id: 'ed899a2f4b5',
web_url: '/commit/ed899a2f4b5',
@@ -42,3 +45,5 @@ export const createMergeRequestResponse = {
iid: '123',
web_url: '/merge_requests/123',
};
+
+export const trackingCategory = 'projects:static_site_editor:show';
diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js
new file mode 100644
index 00000000000..8c9c54f593e
--- /dev/null
+++ b/spec/frontend/static_site_editor/pages/home_spec.js
@@ -0,0 +1,211 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Home from '~/static_site_editor/pages/home.vue';
+import SkeletonLoader from '~/static_site_editor/components/skeleton_loader.vue';
+import EditArea from '~/static_site_editor/components/edit_area.vue';
+import InvalidContentMessage from '~/static_site_editor/components/invalid_content_message.vue';
+import SubmitChangesError from '~/static_site_editor/components/submit_changes_error.vue';
+import submitContentChangesMutation from '~/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql';
+import { SUCCESS_ROUTE } from '~/static_site_editor/router/constants';
+
+import {
+ projectId as project,
+ returnUrl,
+ sourceContent as content,
+ sourceContentTitle as title,
+ sourcePath,
+ username,
+ savedContentMeta,
+ submitChangesError,
+} from '../mock_data';
+
+const localVue = createLocalVue();
+
+localVue.use(Vuex);
+
+describe('static_site_editor/pages/home', () => {
+ let wrapper;
+ let store;
+ let $apollo;
+ let $router;
+ let mutateMock;
+
+ const buildApollo = (queries = {}) => {
+ mutateMock = jest.fn();
+
+ $apollo = {
+ queries: {
+ sourceContent: {
+ loading: false,
+ },
+ ...queries,
+ },
+ mutate: mutateMock,
+ };
+ };
+
+ const buildRouter = () => {
+ $router = {
+ push: jest.fn(),
+ };
+ };
+
+ const buildWrapper = (data = {}) => {
+ wrapper = shallowMount(Home, {
+ localVue,
+ store,
+ mocks: {
+ $apollo,
+ $router,
+ },
+ data() {
+ return {
+ appData: { isSupportedContent: true, returnUrl, project, username, sourcePath },
+ sourceContent: { title, content },
+ ...data,
+ };
+ },
+ });
+ };
+
+ const findEditArea = () => wrapper.find(EditArea);
+ const findInvalidContentMessage = () => wrapper.find(InvalidContentMessage);
+ const findSkeletonLoader = () => wrapper.find(SkeletonLoader);
+ const findSubmitChangesError = () => wrapper.find(SubmitChangesError);
+
+ beforeEach(() => {
+ buildApollo();
+ buildRouter();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ $apollo = null;
+ });
+
+ describe('when content is loaded', () => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('renders edit area', () => {
+ expect(findEditArea().exists()).toBe(true);
+ });
+
+ it('provides source content, returnUrl, and isSavingChanges to the edit area', () => {
+ expect(findEditArea().props()).toMatchObject({
+ title,
+ content,
+ returnUrl,
+ savingChanges: false,
+ });
+ });
+ });
+
+ it('does not render edit area when content is not loaded', () => {
+ buildWrapper({ sourceContent: null });
+
+ expect(findEditArea().exists()).toBe(false);
+ });
+
+ it('renders skeleton loader when content is not loading', () => {
+ buildApollo({
+ sourceContent: {
+ loading: true,
+ },
+ });
+ buildWrapper();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('does not render skeleton loader when content is not loading', () => {
+ buildApollo({
+ sourceContent: {
+ loading: false,
+ },
+ });
+ buildWrapper();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+
+ it('displays invalid content message when content is not supported', () => {
+ buildWrapper({ appData: { isSupportedContent: false } });
+
+ expect(findInvalidContentMessage().exists()).toBe(true);
+ });
+
+ it('does not display invalid content message when content is supported', () => {
+ buildWrapper({ appData: { isSupportedContent: true } });
+
+ expect(findInvalidContentMessage().exists()).toBe(false);
+ });
+
+ describe('when submitting changes fails', () => {
+ beforeEach(() => {
+ mutateMock.mockRejectedValue(new Error(submitChangesError));
+
+ buildWrapper();
+ findEditArea().vm.$emit('submit', { content });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays submit changes error message', () => {
+ expect(findSubmitChangesError().exists()).toBe(true);
+ });
+
+ it('retries submitting changes when retry button is clicked', () => {
+ findSubmitChangesError().vm.$emit('retry');
+
+ expect(mutateMock).toHaveBeenCalled();
+ });
+
+ it('hides submit changes error message when dismiss button is clicked', () => {
+ findSubmitChangesError().vm.$emit('dismiss');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findSubmitChangesError().exists()).toBe(false);
+ });
+ });
+ });
+
+ it('does not display submit changes error when an error does not exist', () => {
+ buildWrapper();
+
+ expect(findSubmitChangesError().exists()).toBe(false);
+ });
+
+ describe('when submitting changes succeeds', () => {
+ const newContent = `new ${content}`;
+
+ beforeEach(() => {
+ mutateMock.mockResolvedValueOnce({ data: { submitContentChanges: savedContentMeta } });
+
+ buildWrapper();
+ findEditArea().vm.$emit('submit', { content: newContent });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('dispatches submitContentChanges mutation', () => {
+ expect(mutateMock).toHaveBeenCalledWith({
+ mutation: submitContentChangesMutation,
+ variables: {
+ input: {
+ content: newContent,
+ project,
+ sourcePath,
+ username,
+ },
+ },
+ });
+ });
+
+ it('transitions to the SUCCESS route', () => {
+ expect($router.push).toHaveBeenCalledWith(SUCCESS_ROUTE);
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/pages/success_spec.js b/spec/frontend/static_site_editor/pages/success_spec.js
new file mode 100644
index 00000000000..d62b67bfa83
--- /dev/null
+++ b/spec/frontend/static_site_editor/pages/success_spec.js
@@ -0,0 +1,78 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Success from '~/static_site_editor/pages/success.vue';
+import SavedChangesMessage from '~/static_site_editor/components/saved_changes_message.vue';
+import { savedContentMeta, returnUrl } from '../mock_data';
+import { HOME_ROUTE } from '~/static_site_editor/router/constants';
+
+const localVue = createLocalVue();
+
+localVue.use(Vuex);
+
+describe('static_site_editor/pages/success', () => {
+ let wrapper;
+ let store;
+ let router;
+
+ const buildRouter = () => {
+ router = {
+ push: jest.fn(),
+ };
+ };
+
+ const buildWrapper = (data = {}) => {
+ wrapper = shallowMount(Success, {
+ localVue,
+ store,
+ mocks: {
+ $router: router,
+ },
+ data() {
+ return {
+ savedContentMeta,
+ appData: {
+ returnUrl,
+ },
+ ...data,
+ };
+ },
+ });
+ };
+
+ const findSavedChangesMessage = () => wrapper.find(SavedChangesMessage);
+
+ beforeEach(() => {
+ buildRouter();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders saved changes message', () => {
+ buildWrapper();
+
+ expect(findSavedChangesMessage().exists()).toBe(true);
+ });
+
+ it('passes returnUrl to the saved changes message', () => {
+ buildWrapper();
+
+ expect(findSavedChangesMessage().props('returnUrl')).toBe(returnUrl);
+ });
+
+ it('passes saved content metadata to the saved changes message', () => {
+ buildWrapper();
+
+ expect(findSavedChangesMessage().props('branch')).toBe(savedContentMeta.branch);
+ expect(findSavedChangesMessage().props('commit')).toBe(savedContentMeta.commit);
+ expect(findSavedChangesMessage().props('mergeRequest')).toBe(savedContentMeta.mergeRequest);
+ });
+
+ it('redirects to the HOME route when content has not been submitted', () => {
+ buildWrapper({ savedContentMeta: null });
+
+ expect(router.push).toHaveBeenCalledWith(HOME_ROUTE);
+ });
+});
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 9a0bd88b57d..a1e9ff4ec4c 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
@@ -1,11 +1,13 @@
import Api from '~/api';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import {
DEFAULT_TARGET_BRANCH,
SUBMIT_CHANGES_BRANCH_ERROR,
SUBMIT_CHANGES_COMMIT_ERROR,
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
+ TRACKING_ACTION_CREATE_COMMIT,
} from '~/static_site_editor/constants';
import generateBranchName from '~/static_site_editor/services/generate_branch_name';
import submitContentChanges from '~/static_site_editor/services/submit_content_changes';
@@ -13,10 +15,12 @@ import submitContentChanges from '~/static_site_editor/services/submit_content_c
import {
username,
projectId,
+ commitBranchResponse,
commitMultipleResponse,
createMergeRequestResponse,
sourcePath,
sourceContent as content,
+ trackingCategory,
} from '../mock_data';
jest.mock('~/static_site_editor/services/generate_branch_name');
@@ -24,15 +28,26 @@ jest.mock('~/static_site_editor/services/generate_branch_name');
describe('submitContentChanges', () => {
const mergeRequestTitle = `Update ${sourcePath} file`;
const branch = 'branch-name';
+ let trackingSpy;
+ let origPage;
beforeEach(() => {
- jest.spyOn(Api, 'createBranch').mockResolvedValue();
+ jest.spyOn(Api, 'createBranch').mockResolvedValue({ data: commitBranchResponse });
jest.spyOn(Api, 'commitMultiple').mockResolvedValue({ data: commitMultipleResponse });
jest
.spyOn(Api, 'createProjectMergeRequest')
.mockResolvedValue({ data: createMergeRequestResponse });
generateBranchName.mockReturnValue(branch);
+
+ origPage = document.body.dataset.page;
+ document.body.dataset.page = trackingCategory;
+ trackingSpy = mockTracking(document.body.dataset.page, undefined, jest.spyOn);
+ });
+
+ afterEach(() => {
+ document.body.dataset.page = origPage;
+ unmockTracking();
});
it('creates a branch named after the username and target branch', () => {
@@ -47,7 +62,7 @@ describe('submitContentChanges', () => {
it('notifies error when branch could not be created', () => {
Api.createBranch.mockRejectedValueOnce();
- expect(submitContentChanges({ username, projectId })).rejects.toThrow(
+ return expect(submitContentChanges({ username, projectId })).rejects.toThrow(
SUBMIT_CHANGES_BRANCH_ERROR,
);
});
@@ -68,10 +83,19 @@ describe('submitContentChanges', () => {
});
});
+ it('sends the correct tracking event when committing content changes', () => {
+ return submitContentChanges({ username, projectId, sourcePath, content }).then(() => {
+ expect(trackingSpy).toHaveBeenCalledWith(
+ document.body.dataset.page,
+ TRACKING_ACTION_CREATE_COMMIT,
+ );
+ });
+ });
+
it('notifies error when content could not be committed', () => {
Api.commitMultiple.mockRejectedValueOnce();
- expect(submitContentChanges({ username, projectId })).rejects.toThrow(
+ return expect(submitContentChanges({ username, projectId })).rejects.toThrow(
SUBMIT_CHANGES_COMMIT_ERROR,
);
});
@@ -92,7 +116,7 @@ describe('submitContentChanges', () => {
it('notifies error when merge request could not be created', () => {
Api.createProjectMergeRequest.mockRejectedValueOnce();
- expect(submitContentChanges({ username, projectId })).rejects.toThrow(
+ return expect(submitContentChanges({ username, projectId })).rejects.toThrow(
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
);
});
diff --git a/spec/frontend/static_site_editor/store/actions_spec.js b/spec/frontend/static_site_editor/store/actions_spec.js
deleted file mode 100644
index 6b0b77f59b7..00000000000
--- a/spec/frontend/static_site_editor/store/actions_spec.js
+++ /dev/null
@@ -1,152 +0,0 @@
-import testAction from 'helpers/vuex_action_helper';
-import createState from '~/static_site_editor/store/state';
-import * as actions from '~/static_site_editor/store/actions';
-import * as mutationTypes from '~/static_site_editor/store/mutation_types';
-import loadSourceContent from '~/static_site_editor/services/load_source_content';
-import submitContentChanges from '~/static_site_editor/services/submit_content_changes';
-
-import createFlash from '~/flash';
-
-import {
- username,
- projectId,
- sourcePath,
- sourceContentTitle as title,
- sourceContent as content,
- savedContentMeta,
- submitChangesError,
-} from '../mock_data';
-
-jest.mock('~/flash');
-jest.mock('~/static_site_editor/services/load_source_content', () => jest.fn());
-jest.mock('~/static_site_editor/services/submit_content_changes', () => jest.fn());
-
-describe('Static Site Editor Store actions', () => {
- let state;
-
- beforeEach(() => {
- state = createState({
- projectId,
- sourcePath,
- });
- });
-
- describe('loadContent', () => {
- describe('on success', () => {
- const payload = { title, content };
-
- beforeEach(() => {
- loadSourceContent.mockResolvedValueOnce(payload);
- });
-
- it('commits receiveContentSuccess', () => {
- testAction(
- actions.loadContent,
- null,
- state,
- [
- { type: mutationTypes.LOAD_CONTENT },
- { type: mutationTypes.RECEIVE_CONTENT_SUCCESS, payload },
- ],
- [],
- );
-
- expect(loadSourceContent).toHaveBeenCalledWith({ projectId, sourcePath });
- });
- });
-
- describe('on error', () => {
- const expectedMutations = [
- { type: mutationTypes.LOAD_CONTENT },
- { type: mutationTypes.RECEIVE_CONTENT_ERROR },
- ];
-
- beforeEach(() => {
- loadSourceContent.mockRejectedValueOnce();
- });
-
- it('commits receiveContentError', () => {
- testAction(actions.loadContent, null, state, expectedMutations);
- });
-
- it('displays flash communicating error', () => {
- return testAction(actions.loadContent, null, state, expectedMutations).then(() => {
- expect(createFlash).toHaveBeenCalledWith(
- 'An error ocurred while loading your content. Please try again.',
- );
- });
- });
- });
- });
-
- describe('setContent', () => {
- it('commits setContent mutation', () => {
- testAction(actions.setContent, content, state, [
- {
- type: mutationTypes.SET_CONTENT,
- payload: content,
- },
- ]);
- });
- });
-
- describe('submitChanges', () => {
- describe('on success', () => {
- beforeEach(() => {
- state = createState({
- projectId,
- content,
- username,
- sourcePath,
- });
- submitContentChanges.mockResolvedValueOnce(savedContentMeta);
- });
-
- it('commits submitChangesSuccess mutation', () => {
- testAction(
- actions.submitChanges,
- null,
- state,
- [
- { type: mutationTypes.SUBMIT_CHANGES },
- { type: mutationTypes.SUBMIT_CHANGES_SUCCESS, payload: savedContentMeta },
- ],
- [],
- );
-
- expect(submitContentChanges).toHaveBeenCalledWith({
- username,
- projectId,
- content,
- sourcePath,
- });
- });
- });
-
- describe('on error', () => {
- const error = new Error(submitChangesError);
- const expectedMutations = [
- { type: mutationTypes.SUBMIT_CHANGES },
- { type: mutationTypes.SUBMIT_CHANGES_ERROR, payload: error.message },
- ];
-
- beforeEach(() => {
- submitContentChanges.mockRejectedValueOnce(error);
- });
-
- it('dispatches receiveContentError', () => {
- testAction(actions.submitChanges, null, state, expectedMutations);
- });
- });
- });
-
- describe('dismissSubmitChangesError', () => {
- it('commits dismissSubmitChangesError', () => {
- testAction(actions.dismissSubmitChangesError, null, state, [
- {
- type: mutationTypes.DISMISS_SUBMIT_CHANGES_ERROR,
- },
- ]);
- });
- });
-});
diff --git a/spec/frontend/static_site_editor/store/getters_spec.js b/spec/frontend/static_site_editor/store/getters_spec.js
deleted file mode 100644
index 5793e344784..00000000000
--- a/spec/frontend/static_site_editor/store/getters_spec.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import createState from '~/static_site_editor/store/state';
-import { contentChanged } from '~/static_site_editor/store/getters';
-import { sourceContent as content } from '../mock_data';
-
-describe('Static Site Editor Store getters', () => {
- describe('contentChanged', () => {
- it('returns true when content and originalContent are different', () => {
- const state = createState({ content, originalContent: 'something else' });
-
- expect(contentChanged(state)).toBe(true);
- });
-
- it('returns false when content and originalContent are the same', () => {
- const state = createState({ content, originalContent: content });
-
- expect(contentChanged(state)).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/static_site_editor/store/mutations_spec.js b/spec/frontend/static_site_editor/store/mutations_spec.js
deleted file mode 100644
index 2441f317d90..00000000000
--- a/spec/frontend/static_site_editor/store/mutations_spec.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import createState from '~/static_site_editor/store/state';
-import mutations from '~/static_site_editor/store/mutations';
-import * as types from '~/static_site_editor/store/mutation_types';
-import {
- sourceContentTitle as title,
- sourceContent as content,
- savedContentMeta,
- submitChangesError,
-} from '../mock_data';
-
-describe('Static Site Editor Store mutations', () => {
- let state;
- const contentLoadedPayload = { title, content };
-
- beforeEach(() => {
- state = createState();
- });
-
- it.each`
- mutation | stateProperty | payload | expectedValue
- ${types.LOAD_CONTENT} | ${'isLoadingContent'} | ${undefined} | ${true}
- ${types.RECEIVE_CONTENT_SUCCESS} | ${'isLoadingContent'} | ${contentLoadedPayload} | ${false}
- ${types.RECEIVE_CONTENT_SUCCESS} | ${'isContentLoaded'} | ${contentLoadedPayload} | ${true}
- ${types.RECEIVE_CONTENT_SUCCESS} | ${'title'} | ${contentLoadedPayload} | ${title}
- ${types.RECEIVE_CONTENT_SUCCESS} | ${'content'} | ${contentLoadedPayload} | ${content}
- ${types.RECEIVE_CONTENT_SUCCESS} | ${'originalContent'} | ${contentLoadedPayload} | ${content}
- ${types.RECEIVE_CONTENT_ERROR} | ${'isLoadingContent'} | ${undefined} | ${false}
- ${types.SET_CONTENT} | ${'content'} | ${content} | ${content}
- ${types.SUBMIT_CHANGES} | ${'isSavingChanges'} | ${undefined} | ${true}
- ${types.SUBMIT_CHANGES_SUCCESS} | ${'savedContentMeta'} | ${savedContentMeta} | ${savedContentMeta}
- ${types.SUBMIT_CHANGES_SUCCESS} | ${'isSavingChanges'} | ${savedContentMeta} | ${false}
- ${types.SUBMIT_CHANGES_ERROR} | ${'isSavingChanges'} | ${undefined} | ${false}
- ${types.SUBMIT_CHANGES_ERROR} | ${'submitChangesError'} | ${submitChangesError} | ${submitChangesError}
- ${types.DISMISS_SUBMIT_CHANGES_ERROR} | ${'submitChangesError'} | ${undefined} | ${''}
- `(
- '$mutation sets $stateProperty to $expectedValue',
- ({ mutation, stateProperty, payload, expectedValue }) => {
- mutations[mutation](state, payload);
- expect(state[stateProperty]).toBe(expectedValue);
- },
- );
-
- it(`${types.SUBMIT_CHANGES_SUCCESS} sets originalContent to content current value`, () => {
- const editedContent = `${content} plus something else`;
-
- state = createState({
- originalContent: content,
- content: editedContent,
- });
- mutations[types.SUBMIT_CHANGES_SUCCESS](state);
-
- expect(state.originalContent).toBe(state.content);
- });
-});
diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js
index 30a8e138df2..08a26d46618 100644
--- a/spec/frontend/tracking_spec.js
+++ b/spec/frontend/tracking_spec.js
@@ -4,6 +4,7 @@ import Tracking, { initUserTracking } from '~/tracking';
describe('Tracking', () => {
let snowplowSpy;
let bindDocumentSpy;
+ let trackLoadEventsSpy;
beforeEach(() => {
window.snowplow = window.snowplow || (() => {});
@@ -18,6 +19,7 @@ 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', () => {
@@ -44,10 +46,11 @@ describe('Tracking', () => {
expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking');
expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking');
- window.snowplowOptions = Object.assign({}, window.snowplowOptions, {
+ window.snowplowOptions = {
+ ...window.snowplowOptions,
formTracking: true,
linkClickTracking: true,
- });
+ };
initUserTracking();
expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking');
@@ -58,6 +61,11 @@ describe('Tracking', () => {
initUserTracking();
expect(bindDocumentSpy).toHaveBeenCalled();
});
+
+ it('tracks page loaded events', () => {
+ initUserTracking();
+ expect(trackLoadEventsSpy).toHaveBeenCalled();
+ });
});
describe('.event', () => {
@@ -127,6 +135,7 @@ describe('Tracking', () => {
<input type="checkbox" data-track-event="toggle_checkbox" value="_value_" checked/>
<input class="dropdown" data-track-event="toggle_dropdown"/>
<div data-track-event="nested_event"><span class="nested"></span></div>
+ <input data-track-eventbogus="click_bogusinput" data-track-label="_label_" value="_value_"/>
`);
});
@@ -139,6 +148,12 @@ describe('Tracking', () => {
});
});
+ it('does not bind to clicks on elements without [data-track-event]', () => {
+ trigger('[data-track-eventbogus="click_bogusinput"]');
+
+ expect(eventSpy).not.toHaveBeenCalled();
+ });
+
it('allows value override with the data-track-value attribute', () => {
trigger('[data-track-event="click_input2"]');
@@ -178,6 +193,44 @@ describe('Tracking', () => {
});
});
+ describe('tracking page loaded events', () => {
+ let eventSpy;
+
+ beforeEach(() => {
+ eventSpy = jest.spyOn(Tracking, 'event');
+ setHTMLFixture(`
+ <input data-track-event="render" data-track-label="label1" value="_value_" data-track-property="_property_"/>
+ <span data-track-event="render" data-track-label="label2" data-track-value="_value_">
+ Something
+ </span>
+ <input data-track-event="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_"/>
+ `);
+ Tracking.trackLoadEvents('_category_'); // only happens once
+ });
+
+ it('sends tracking events when [data-track-event="render"] is on an element', () => {
+ expect(eventSpy.mock.calls).toEqual([
+ [
+ '_category_',
+ 'render',
+ {
+ label: 'label1',
+ value: '_value_',
+ property: '_property_',
+ },
+ ],
+ [
+ '_category_',
+ 'render',
+ {
+ label: 'label2',
+ value: '_value_',
+ },
+ ],
+ ]);
+ });
+ });
+
describe('tracking mixin', () => {
describe('trackingOptions', () => {
it('return the options defined on initialisation', () => {
diff --git a/spec/frontend/users_select/utils_spec.js b/spec/frontend/users_select/utils_spec.js
new file mode 100644
index 00000000000..a09935d8a04
--- /dev/null
+++ b/spec/frontend/users_select/utils_spec.js
@@ -0,0 +1,33 @@
+import $ from 'jquery';
+import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from '~/users_select/utils';
+
+const options = {
+ fooBar: 'baz',
+ activeUserId: 1,
+};
+
+describe('getAjaxUsersSelectOptions', () => {
+ it('returns options built from select data attributes', () => {
+ const $select = $('<select />', { 'data-foo-bar': 'baz', 'data-user-id': 1 });
+
+ expect(
+ getAjaxUsersSelectOptions($select, { fooBar: 'fooBar', activeUserId: 'user-id' }),
+ ).toEqual(options);
+ });
+});
+
+describe('getAjaxUsersSelectParams', () => {
+ it('returns query parameters built from provided options', () => {
+ expect(
+ getAjaxUsersSelectParams(options, {
+ foo_bar: 'fooBar',
+ active_user_id: 'activeUserId',
+ non_existent_key: 'nonExistentKey',
+ }),
+ ).toEqual({
+ foo_bar: 'baz',
+ active_user_id: 1,
+ non_existent_key: null,
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js
index a7ecb863cfb..8a604355625 100644
--- a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js
@@ -61,7 +61,7 @@ describe('Merge Request Collapsible Extension', () => {
describe('while loading', () => {
beforeEach(() => {
- mountComponent(Object.assign({}, data, { isLoading: true }));
+ mountComponent({ ...data, isLoading: true });
});
it('renders the buttons disabled', () => {
@@ -86,7 +86,7 @@ describe('Merge Request Collapsible Extension', () => {
describe('with error', () => {
beforeEach(() => {
- mountComponent(Object.assign({}, data, { hasError: true }));
+ mountComponent({ ...data, hasError: true });
});
it('does not render the buttons', () => {
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js
new file mode 100644
index 00000000000..5f3a8654990
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js
@@ -0,0 +1,100 @@
+import { mount } from '@vue/test-utils';
+import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue';
+import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue';
+import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue';
+import { mockStore } from '../mock_data';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+
+describe('MrWidgetPipelineContainer', () => {
+ let wrapper;
+ let mock;
+
+ const factory = (props = {}) => {
+ wrapper = mount(MrWidgetPipelineContainer, {
+ propsData: {
+ mr: { ...mockStore },
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet().reply(200, {});
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when pre merge', () => {
+ beforeEach(() => {
+ factory();
+ });
+
+ it('renders pipeline', () => {
+ expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true);
+ expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({
+ pipeline: mockStore.pipeline,
+ pipelineCoverageDelta: mockStore.pipelineCoverageDelta,
+ ciStatus: mockStore.ciStatus,
+ hasCi: mockStore.hasCI,
+ sourceBranch: mockStore.sourceBranch,
+ sourceBranchLink: mockStore.sourceBranchLink,
+ });
+ });
+
+ it('renders deployments', () => {
+ const expectedProps = mockStore.deployments.map(dep =>
+ expect.objectContaining({
+ deployment: dep,
+ showMetrics: false,
+ }),
+ );
+
+ const deployments = wrapper.findAll('.mr-widget-extension .js-pre-deployment');
+
+ expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps);
+ });
+ });
+
+ describe('when post merge', () => {
+ beforeEach(() => {
+ factory({
+ isPostMerge: true,
+ });
+ });
+
+ it('renders pipeline', () => {
+ expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true);
+ expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({
+ pipeline: mockStore.mergePipeline,
+ pipelineCoverageDelta: mockStore.pipelineCoverageDelta,
+ ciStatus: mockStore.ciStatus,
+ hasCi: mockStore.hasCI,
+ sourceBranch: mockStore.targetBranch,
+ sourceBranchLink: mockStore.targetBranch,
+ });
+ });
+
+ it('renders deployments', () => {
+ const expectedProps = mockStore.postMergeDeployments.map(dep =>
+ expect.objectContaining({
+ deployment: dep,
+ showMetrics: true,
+ }),
+ );
+
+ const deployments = wrapper.findAll('.mr-widget-extension .js-post-deployment');
+
+ expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps);
+ });
+ });
+
+ describe('with artifacts path', () => {
+ it('renders the artifacts app', () => {
+ expect(wrapper.find(ArtifactsApp).isVisible()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js
index 1951b56587a..91e95b2bdb1 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js
@@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils';
import MockAdapter from 'axios-mock-adapter';
import MrWidgetTerraformPlan from '~/vue_merge_request_widget/components/mr_widget_terraform_plan.vue';
+import Poll from '~/lib/utils/poll';
const plan = {
create: 10,
@@ -57,11 +58,23 @@ describe('MrWidgetTerraformPlan', () => {
});
describe('successful poll', () => {
+ let pollRequest;
+ let pollStop;
+
beforeEach(() => {
+ pollRequest = jest.spyOn(Poll.prototype, 'makeRequest');
+ pollStop = jest.spyOn(Poll.prototype, 'stop');
+
mockPollingApi(200, { 'tfplan.json': plan }, {});
+
return mountWrapper();
});
+ afterEach(() => {
+ pollRequest.mockRestore();
+ pollStop.mockRestore();
+ });
+
it('content change text', () => {
expect(wrapper.find(GlSprintf).exists()).toBe(true);
});
@@ -69,6 +82,11 @@ describe('MrWidgetTerraformPlan', () => {
it('renders button when url is found', () => {
expect(wrapper.find('a').text()).toContain('View full log');
});
+
+ it('does not make additional requests after poll is successful', () => {
+ expect(pollRequest).toHaveBeenCalledTimes(1);
+ expect(pollStop).toHaveBeenCalledTimes(1);
+ });
});
describe('polling fails', () => {
diff --git a/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js
new file mode 100644
index 00000000000..026ea0e4d0a
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js
@@ -0,0 +1,165 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import { TEST_HOST } from 'helpers/test_constants';
+import axios from '~/lib/utils/axios_utils';
+import {
+ setEndpoint,
+ requestArtifacts,
+ clearEtagPoll,
+ stopPolling,
+ fetchArtifacts,
+ receiveArtifactsSuccess,
+ receiveArtifactsError,
+} from '~/vue_merge_request_widget/stores/artifacts_list/actions';
+import state from '~/vue_merge_request_widget/stores/artifacts_list/state';
+import * as types from '~/vue_merge_request_widget/stores/artifacts_list/mutation_types';
+
+describe('Artifacts App Store Actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('setEndpoint', () => {
+ it('should commit SET_ENDPOINT mutation', done => {
+ testAction(
+ setEndpoint,
+ 'endpoint.json',
+ mockedState,
+ [{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestArtifacts', () => {
+ it('should commit REQUEST_ARTIFACTS mutation', done => {
+ testAction(
+ requestArtifacts,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_ARTIFACTS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchArtifacts', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ stopPolling();
+ clearEtagPoll();
+ });
+
+ describe('success', () => {
+ it('dispatches requestArtifacts and receiveArtifactsSuccess ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, [
+ {
+ text: 'result.txt',
+ url: 'asda',
+ job_name: 'generate-artifact',
+ job_path: 'asda',
+ },
+ ]);
+
+ testAction(
+ fetchArtifacts,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestArtifacts',
+ },
+ {
+ payload: {
+ data: [
+ {
+ text: 'result.txt',
+ url: 'asda',
+ job_name: 'generate-artifact',
+ job_path: 'asda',
+ },
+ ],
+ status: 200,
+ },
+ type: 'receiveArtifactsSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
+ });
+
+ it('dispatches requestArtifacts and receiveArtifactsError ', done => {
+ testAction(
+ fetchArtifacts,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestArtifacts',
+ },
+ {
+ type: 'receiveArtifactsError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('receiveArtifactsSuccess', () => {
+ it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', done => {
+ testAction(
+ receiveArtifactsSuccess,
+ { data: { summary: {} }, status: 200 },
+ mockedState,
+ [{ type: types.RECEIVE_ARTIFACTS_SUCCESS, payload: { summary: {} } }],
+ [],
+ done,
+ );
+ });
+
+ it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', done => {
+ testAction(
+ receiveArtifactsSuccess,
+ { data: { summary: {} }, status: 204 },
+ mockedState,
+ [],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveArtifactsError', () => {
+ it('should commit RECEIVE_ARTIFACTS_ERROR mutation', done => {
+ testAction(
+ receiveArtifactsError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_ARTIFACTS_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+});
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 0f5d47b3bfe..e54cd345a37 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
@@ -35,10 +35,12 @@ describe('getStateKey', () => {
expect(bound()).toEqual('autoMergeEnabled');
+ context.canMerge = true;
context.isSHAMismatch = true;
expect(bound()).toEqual('shaMismatch');
+ context.canMerge = false;
context.isPipelineBlocked = true;
expect(bound()).toEqual('pipelineBlocked');
@@ -100,4 +102,26 @@ describe('getStateKey', () => {
expect(bound()).toEqual('rebase');
});
+
+ it.each`
+ canMerge | isSHAMismatch | stateKey
+ ${true} | ${true} | ${'shaMismatch'}
+ ${false} | ${true} | ${'notAllowedToMerge'}
+ ${false} | ${false} | ${'notAllowedToMerge'}
+ `(
+ 'returns $stateKey when canMerge is $canMerge and isSHAMismatch is $isSHAMismatch',
+ ({ canMerge, isSHAMismatch, stateKey }) => {
+ const bound = getStateKey.bind(
+ {
+ canMerge,
+ isSHAMismatch,
+ },
+ {
+ commits_count: 2,
+ },
+ );
+
+ expect(bound()).toEqual(stateKey);
+ },
+ );
});
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
new file mode 100644
index 00000000000..48326eda404
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js
@@ -0,0 +1,112 @@
+import MergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store';
+import { stateKey } from '~/vue_merge_request_widget/stores/state_maps';
+import mockData from '../mock_data';
+
+describe('MergeRequestStore', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new MergeRequestStore(mockData);
+ });
+
+ describe('setData', () => {
+ it('should set isSHAMismatch when the diff SHA changes', () => {
+ store.setData({ ...mockData, diff_head_sha: 'a-different-string' });
+
+ expect(store.isSHAMismatch).toBe(true);
+ });
+
+ it('should not set isSHAMismatch when other data changes', () => {
+ store.setData({ ...mockData, work_in_progress: !mockData.work_in_progress });
+
+ expect(store.isSHAMismatch).toBe(false);
+ });
+
+ it('should update cached sha after rebasing', () => {
+ store.setData({ ...mockData, diff_head_sha: 'abc123' }, true);
+
+ expect(store.isSHAMismatch).toBe(false);
+ expect(store.sha).toBe('abc123');
+ });
+
+ describe('isPipelinePassing', () => {
+ it('is true when the CI status is `success`', () => {
+ store.setData({ ...mockData, ci_status: 'success' });
+
+ expect(store.isPipelinePassing).toBe(true);
+ });
+
+ it('is true when the CI status is `success-with-warnings`', () => {
+ store.setData({ ...mockData, ci_status: 'success-with-warnings' });
+
+ expect(store.isPipelinePassing).toBe(true);
+ });
+
+ it('is false when the CI status is `failed`', () => {
+ store.setData({ ...mockData, ci_status: 'failed' });
+
+ expect(store.isPipelinePassing).toBe(false);
+ });
+
+ it('is false when the CI status is anything except `success`', () => {
+ store.setData({ ...mockData, ci_status: 'foobarbaz' });
+
+ expect(store.isPipelinePassing).toBe(false);
+ });
+ });
+
+ describe('isPipelineSkipped', () => {
+ it('should set isPipelineSkipped=true when the CI status is `skipped`', () => {
+ store.setData({ ...mockData, ci_status: 'skipped' });
+
+ expect(store.isPipelineSkipped).toBe(true);
+ });
+
+ it('should set isPipelineSkipped=false when the CI status is anything except `skipped`', () => {
+ store.setData({ ...mockData, ci_status: 'foobarbaz' });
+
+ expect(store.isPipelineSkipped).toBe(false);
+ });
+ });
+
+ describe('isNothingToMergeState', () => {
+ it('returns true when nothingToMerge', () => {
+ store.state = stateKey.nothingToMerge;
+
+ expect(store.isNothingToMergeState).toBe(true);
+ });
+
+ it('returns false when not nothingToMerge', () => {
+ store.state = 'state';
+
+ expect(store.isNothingToMergeState).toBe(false);
+ });
+ });
+ });
+
+ describe('setPaths', () => {
+ it('should set the add ci config path', () => {
+ store.setData({ ...mockData });
+
+ expect(store.mergeRequestAddCiConfigPath).toBe('/group2/project2/new/pipeline');
+ });
+
+ it('should set humanAccess=Maintainer when user has that role', () => {
+ store.setData({ ...mockData });
+
+ expect(store.humanAccess).toBe('Maintainer');
+ });
+
+ it('should set pipelinesEmptySvgPath', () => {
+ store.setData({ ...mockData });
+
+ expect(store.pipelinesEmptySvgPath).toBe('/path/to/svg');
+ });
+
+ it('should set newPipelinePath', () => {
+ store.setData({ ...mockData });
+
+ expect(store.newPipelinePath).toBe('/group2/project2/pipelines/new');
+ });
+ });
+});
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 4cd03a690e9..408f9d57147 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
@@ -24,12 +24,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
<b-input-group-stub
tag="div"
>
- <b-input-group-prepend-stub
- tag="div"
- >
-
- <!---->
- </b-input-group-prepend-stub>
+ <!---->
<b-form-input-stub
class="gl-form-input"
@@ -44,18 +39,14 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
>
<gl-button-stub
category="tertiary"
+ class="d-inline-flex"
data-clipboard-text="ssh://foo.bar"
- icon=""
+ data-qa-selector="copy_ssh_url_button"
+ icon="copy-to-clipboard"
size="medium"
title="Copy URL"
variant="default"
- >
- <gl-icon-stub
- name="copy-to-clipboard"
- size="16"
- title="Copy URL"
- />
- </gl-button-stub>
+ />
</b-input-group-append-stub>
</b-input-group-stub>
</div>
@@ -74,12 +65,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
<b-input-group-stub
tag="div"
>
- <b-input-group-prepend-stub
- tag="div"
- >
-
- <!---->
- </b-input-group-prepend-stub>
+ <!---->
<b-form-input-stub
class="gl-form-input"
@@ -94,18 +80,14 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
>
<gl-button-stub
category="tertiary"
+ class="d-inline-flex"
data-clipboard-text="http://foo.bar"
- icon=""
+ data-qa-selector="copy_http_url_button"
+ icon="copy-to-clipboard"
size="medium"
title="Copy URL"
variant="default"
- >
- <gl-icon-stub
- name="copy-to-clipboard"
- size="16"
- title="Copy URL"
- />
- </gl-button-stub>
+ />
</b-input-group-append-stub>
</b-input-group-stub>
</div>
diff --git a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap
index 5347d1efc48..db174346729 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap
@@ -1,16 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Code Block matches snapshot 1`] = `
+exports[`Code Block with default props renders correctly 1`] = `
<pre
class="code-block rounded"
>
-
<code
class="d-block"
>
test-code
</code>
-
+</pre>
+`;
+exports[`Code Block with maxHeight set to "200px" renders correctly 1`] = `
+<pre
+ class="code-block rounded"
+ style="max-height: 200px; overflow-y: auto;"
+>
+ <code
+ class="d-block"
+ >
+ test-code
+ </code>
</pre>
`;
diff --git a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap
index 72370cb5b52..1d8e04b83a3 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap
@@ -1,6 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Identicon matches snapshot 1`] = `
+exports[`Identicon entity id is a GraphQL id matches snapshot 1`] = `
+<div
+ class="avatar identicon s40 bg2"
+>
+
+ E
+
+</div>
+`;
+
+exports[`Identicon entity id is a number matches snapshot 1`] = `
<div
class="avatar identicon s40 bg2"
>
diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js
index bb3e60ab9e2..0abb72ace2e 100644
--- a/spec/frontend/vue_shared/components/awards_list_spec.js
+++ b/spec/frontend/vue_shared/components/awards_list_spec.js
@@ -210,4 +210,46 @@ describe('vue_shared/components/awards_list', () => {
expect(buttons.wrappers.every(x => x.classes('disabled'))).toBe(true);
});
});
+
+ describe('with default awards', () => {
+ beforeEach(() => {
+ createComponent({
+ awards: [createAward(EMOJI_SMILE, USERS.marie), createAward(EMOJI_100, USERS.marie)],
+ canAwardEmoji: true,
+ currentUserId: USERS.root.id,
+ // Let's assert that it puts thumbsup and thumbsdown in the right order still
+ defaultAwards: [EMOJI_THUMBSDOWN, EMOJI_100, EMOJI_THUMBSUP],
+ });
+ });
+
+ it('shows awards in correct order', () => {
+ expect(findAwardsData()).toEqual([
+ {
+ classes: ['btn', 'award-control'],
+ count: 0,
+ html: matchingEmojiTag(EMOJI_THUMBSUP),
+ title: '',
+ },
+ {
+ classes: ['btn', 'award-control'],
+ count: 0,
+ html: matchingEmojiTag(EMOJI_THUMBSDOWN),
+ title: '',
+ },
+ // We expect the EMOJI_100 before the EMOJI_SMILE because it was given as a defaultAward
+ {
+ classes: ['btn', 'award-control'],
+ count: 1,
+ html: matchingEmojiTag(EMOJI_100),
+ title: 'Marie',
+ },
+ {
+ classes: ['btn', 'award-control'],
+ count: 1,
+ html: matchingEmojiTag(EMOJI_SMILE),
+ title: 'Marie',
+ },
+ ]);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap
index 87f2a8f9eff..4909d2d4226 100644
--- a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap
+++ b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap
@@ -2,7 +2,8 @@
exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = `
<div
- class="file-content code js-syntax-highlight qa-file-content"
+ class="file-content code js-syntax-highlight"
+ data-qa-selector="file_content"
>
<div
class="line-numbers"
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 ce3f289eb6e..5cf42ecdc1d 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
@@ -1,5 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import RichViewer from '~/vue_shared/components/blob_viewers/rich_viewer.vue';
+import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
import { handleBlobRichViewer } from '~/blob/viewer';
jest.mock('~/blob/viewer');
@@ -33,4 +34,8 @@ describe('Blob Rich Viewer component', () => {
it('queries for advanced viewer', () => {
expect(handleBlobRichViewer).toHaveBeenCalledWith(expect.anything(), defaultType);
});
+
+ it('is using Markdown View Field', () => {
+ expect(wrapper.contains(MarkdownFieldView)).toBe(true);
+ });
});
diff --git a/spec/frontend/vue_shared/components/ci_badge_link_spec.js b/spec/frontend/vue_shared/components/ci_badge_link_spec.js
new file mode 100644
index 00000000000..f656bb0b60d
--- /dev/null
+++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js
@@ -0,0 +1,100 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import ciBadge from '~/vue_shared/components/ci_badge_link.vue';
+
+describe('CI Badge Link Component', () => {
+ let CIBadge;
+ let vm;
+
+ const statuses = {
+ canceled: {
+ text: 'canceled',
+ label: 'canceled',
+ group: 'canceled',
+ icon: 'status_canceled',
+ details_path: 'status/canceled',
+ },
+ created: {
+ text: 'created',
+ label: 'created',
+ group: 'created',
+ icon: 'status_created',
+ details_path: 'status/created',
+ },
+ failed: {
+ text: 'failed',
+ label: 'failed',
+ group: 'failed',
+ icon: 'status_failed',
+ details_path: 'status/failed',
+ },
+ manual: {
+ text: 'manual',
+ label: 'manual action',
+ group: 'manual',
+ icon: 'status_manual',
+ details_path: 'status/manual',
+ },
+ pending: {
+ text: 'pending',
+ label: 'pending',
+ group: 'pending',
+ icon: 'status_pending',
+ details_path: 'status/pending',
+ },
+ running: {
+ text: 'running',
+ label: 'running',
+ group: 'running',
+ icon: 'status_running',
+ details_path: 'status/running',
+ },
+ skipped: {
+ text: 'skipped',
+ label: 'skipped',
+ group: 'skipped',
+ icon: 'status_skipped',
+ details_path: 'status/skipped',
+ },
+ success_warining: {
+ text: 'passed',
+ label: 'passed',
+ group: 'success-with-warnings',
+ icon: 'status_warning',
+ details_path: 'status/warning',
+ },
+ success: {
+ text: 'passed',
+ label: 'passed',
+ group: 'passed',
+ icon: 'status_success',
+ details_path: 'status/passed',
+ },
+ };
+
+ beforeEach(() => {
+ CIBadge = Vue.extend(ciBadge);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render each status badge', () => {
+ Object.keys(statuses).map(status => {
+ vm = mountComponent(CIBadge, { status: statuses[status] });
+
+ expect(vm.$el.getAttribute('href')).toEqual(statuses[status].details_path);
+ expect(vm.$el.textContent.trim()).toEqual(statuses[status].text);
+ expect(vm.$el.getAttribute('class')).toContain(`ci-status ci-${statuses[status].group}`);
+ expect(vm.$el.querySelector('svg')).toBeDefined();
+ return vm;
+ });
+ });
+
+ it('should not render label', () => {
+ vm = mountComponent(CIBadge, { status: statuses.canceled, showText: false });
+
+ expect(vm.$el.textContent.trim()).toEqual('');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/ci_icon_spec.js b/spec/frontend/vue_shared/components/ci_icon_spec.js
new file mode 100644
index 00000000000..63afe631063
--- /dev/null
+++ b/spec/frontend/vue_shared/components/ci_icon_spec.js
@@ -0,0 +1,122 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import ciIcon from '~/vue_shared/components/ci_icon.vue';
+
+describe('CI Icon component', () => {
+ const Component = Vue.extend(ciIcon);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render a span element with an svg', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_success',
+ },
+ });
+
+ expect(vm.$el.tagName).toEqual('SPAN');
+ expect(vm.$el.querySelector('span > svg')).toBeDefined();
+ });
+
+ it('should render a success status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_success',
+ group: 'success',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-success')).toEqual(true);
+ });
+
+ it('should render a failed status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_failed',
+ group: 'failed',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-failed')).toEqual(true);
+ });
+
+ it('should render success with warnings status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_warning',
+ group: 'warning',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-warning')).toEqual(true);
+ });
+
+ it('should render pending status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_pending',
+ group: 'pending',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-pending')).toEqual(true);
+ });
+
+ it('should render running status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_running',
+ group: 'running',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-running')).toEqual(true);
+ });
+
+ it('should render created status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_created',
+ group: 'created',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-created')).toEqual(true);
+ });
+
+ it('should render skipped status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_skipped',
+ group: 'skipped',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-skipped')).toEqual(true);
+ });
+
+ it('should render canceled status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_canceled',
+ group: 'canceled',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-canceled')).toEqual(true);
+ });
+
+ it('should render status for manual action', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_manual',
+ group: 'manual',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-manual')).toEqual(true);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/code_block_spec.js b/spec/frontend/vue_shared/components/code_block_spec.js
index 0d21dd94f7c..60b0b0b566b 100644
--- a/spec/frontend/vue_shared/components/code_block_spec.js
+++ b/spec/frontend/vue_shared/components/code_block_spec.js
@@ -4,10 +4,15 @@ import CodeBlock from '~/vue_shared/components/code_block.vue';
describe('Code Block', () => {
let wrapper;
- const createComponent = () => {
+ const defaultProps = {
+ code: 'test-code',
+ };
+
+ const createComponent = (props = {}) => {
wrapper = shallowMount(CodeBlock, {
propsData: {
- code: 'test-code',
+ ...defaultProps,
+ ...props,
},
});
};
@@ -17,9 +22,23 @@ describe('Code Block', () => {
wrapper = null;
});
- it('matches snapshot', () => {
- createComponent();
+ describe('with default props', () => {
+ beforeEach(() => {
+ createComponent();
+ });
- expect(wrapper.element).toMatchSnapshot();
+ it('renders correctly', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('with maxHeight set to "200px"', () => {
+ beforeEach(() => {
+ createComponent({ maxHeight: '200px' });
+ });
+
+ it('renders correctly', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js
new file mode 100644
index 00000000000..16e7e4dd5cc
--- /dev/null
+++ b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js
@@ -0,0 +1,21 @@
+import { mount } from '@vue/test-utils';
+import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants';
+import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
+import '~/behaviors/markdown/render_gfm';
+
+describe('ContentViewer', () => {
+ let wrapper;
+
+ it.each`
+ path | type | selector | viewer
+ ${GREEN_BOX_IMAGE_URL} | ${'image'} | ${'img'} | ${'<image-viewer>'}
+ ${'myfile.md'} | ${'markdown'} | ${'.md-previewer'} | ${'<markdown-viewer>'}
+ ${'myfile.abc'} | ${undefined} | ${'[download]'} | ${'<download-viewer>'}
+ `('renders $viewer when file type="$type"', ({ path, type, selector }) => {
+ wrapper = mount(ContentViewer, {
+ propsData: { path, fileSize: 1024, type },
+ });
+
+ expect(wrapper.find(selector).element).toExist();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js b/spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js
new file mode 100644
index 00000000000..facdaa86f84
--- /dev/null
+++ b/spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js
@@ -0,0 +1,20 @@
+import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
+
+describe('viewerInformationForPath', () => {
+ it.each`
+ path | type
+ ${'p/somefile.jpg'} | ${'image'}
+ ${'p/somefile.jpeg'} | ${'image'}
+ ${'p/somefile.bmp'} | ${'image'}
+ ${'p/somefile.ico'} | ${'image'}
+ ${'p/somefile.png'} | ${'image'}
+ ${'p/somefile.gif'} | ${'image'}
+ ${'p/somefile.md'} | ${'markdown'}
+ ${'p/md'} | ${undefined}
+ ${'p/png'} | ${undefined}
+ ${'p/md.png/a'} | ${undefined}
+ ${'p/some-file.php'} | ${undefined}
+ `('when path=$path, type=$type', ({ path, type }) => {
+ expect(viewerInformationForPath(path)?.id).toBe(type);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js
new file mode 100644
index 00000000000..b83602e7bfc
--- /dev/null
+++ b/spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js
@@ -0,0 +1,28 @@
+import { mount } from '@vue/test-utils';
+import DownloadViewer from '~/vue_shared/components/content_viewer/viewers/download_viewer.vue';
+
+describe('DownloadViewer', () => {
+ let wrapper;
+
+ it.each`
+ path | filePath | fileSize | renderedName | renderedSize
+ ${'somepath/test.abc'} | ${undefined} | ${1024} | ${'test.abc'} | ${'1.00 KiB'}
+ ${'somepath/test.abc'} | ${undefined} | ${null} | ${'test.abc'} | ${''}
+ ${'data:application/unknown;base64,U0VMRUNU'} | ${'somepath/test.abc'} | ${2048} | ${'test.abc'} | ${'2.00 KiB'}
+ `(
+ 'renders the file name as "$renderedName" and shows size as "$renderedSize"',
+ ({ path, filePath, fileSize, renderedName, renderedSize }) => {
+ wrapper = mount(DownloadViewer, {
+ propsData: { path, filePath, fileSize },
+ });
+
+ const renderedFileInfo = wrapper.find('.file-info').text();
+
+ expect(renderedFileInfo).toContain(renderedName);
+ expect(renderedFileInfo).toContain(renderedSize);
+
+ expect(wrapper.find('.btn.btn-default').text()).toContain('Download');
+ expect(wrapper.find('.btn.btn-default').element).toHaveAttr('download', 'test.abc');
+ },
+ );
+});
diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js
index ef785b9f0f5..31e843297fa 100644
--- a/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js
@@ -1,45 +1,36 @@
-import { shallowMount } from '@vue/test-utils';
-
+import { mount } from '@vue/test-utils';
import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants';
import ImageViewer from '~/vue_shared/components/content_viewer/viewers/image_viewer.vue';
describe('Image Viewer', () => {
- const requiredProps = {
- path: GREEN_BOX_IMAGE_URL,
- renderInfo: true,
- };
let wrapper;
- let imageInfo;
-
- function createElement({ props, includeRequired = true } = {}) {
- const data = includeRequired ? { ...requiredProps, ...props } : { ...props };
- wrapper = shallowMount(ImageViewer, {
- propsData: data,
+ it('renders image preview', () => {
+ wrapper = mount(ImageViewer, {
+ propsData: { path: GREEN_BOX_IMAGE_URL, fileSize: 1024 },
});
- imageInfo = wrapper.find('.image-info');
- }
-
- describe('file sizes', () => {
- it('should show the humanized file size when `renderInfo` is true and there is size info', () => {
- createElement({ props: { fileSize: 1024 } });
-
- expect(imageInfo.text()).toContain('1.00 KiB');
- });
-
- it('should not show the humanized file size when `renderInfo` is true and there is no size', () => {
- const FILESIZE_RE = /\d+(\.\d+)?\s*([KMGTP]i)*B/;
- createElement({ props: { fileSize: 0 } });
-
- // It shouldn't show any filesize info
- expect(imageInfo.text()).not.toMatch(FILESIZE_RE);
- });
-
- it('should not show any image information when `renderInfo` is false', () => {
- createElement({ props: { renderInfo: false } });
+ expect(wrapper.find('img').element).toHaveAttr('src', GREEN_BOX_IMAGE_URL);
+ });
- expect(imageInfo.exists()).toBe(false);
- });
+ describe('file sizes', () => {
+ it.each`
+ fileSize | renderInfo | elementExists | humanizedFileSize
+ ${1024} | ${true} | ${true} | ${'1.00 KiB'}
+ ${0} | ${true} | ${true} | ${''}
+ ${1024} | ${false} | ${false} | ${undefined}
+ `(
+ 'shows file size as "$humanizedFileSize", if fileSize=$fileSize and renderInfo=$renderInfo',
+ ({ fileSize, renderInfo, elementExists, humanizedFileSize }) => {
+ wrapper = mount(ImageViewer, {
+ propsData: { path: GREEN_BOX_IMAGE_URL, fileSize, renderInfo },
+ });
+
+ const imageInfo = wrapper.find('.image-info');
+
+ expect(imageInfo.exists()).toBe(elementExists);
+ expect(imageInfo.element?.textContent.trim()).toBe(humanizedFileSize);
+ },
+ );
});
});
diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js
new file mode 100644
index 00000000000..8d3fcdd48d2
--- /dev/null
+++ b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js
@@ -0,0 +1,114 @@
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import MockAdapter from 'axios-mock-adapter';
+import { mount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import MarkdownViewer from '~/vue_shared/components/content_viewer/viewers/markdown_viewer.vue';
+
+describe('MarkdownViewer', () => {
+ let wrapper;
+ let mock;
+
+ const createComponent = props => {
+ wrapper = mount(MarkdownViewer, {
+ propsData: {
+ ...props,
+ path: 'test.md',
+ content: '* Test',
+ projectPath: 'testproject',
+ type: 'markdown',
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ jest.spyOn(axios, 'post');
+ jest.spyOn($.fn, 'renderGFM');
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ beforeEach(() => {
+ mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(200, {
+ body: '<b>testing</b> {{gl_md_img_1}}',
+ });
+ });
+
+ it('renders an animation container while the markdown is loading', () => {
+ createComponent();
+
+ expect(wrapper.find('.animation-container')).toExist();
+ });
+
+ it('renders markdown preview preview renders and loads rendered markdown from server', () => {
+ createComponent();
+
+ return waitForPromises().then(() => {
+ expect(wrapper.find('.md-previewer').text()).toContain('testing');
+ });
+ });
+
+ it('receives the filePath and commitSha as a parameters and passes them on to the server', () => {
+ createComponent({ filePath: 'foo/test.md', commitSha: 'abcdef' });
+
+ expect(axios.post).toHaveBeenCalledWith(
+ `${gon.relative_url_root}/testproject/preview_markdown`,
+ { path: 'foo/test.md', text: '* Test', ref: 'abcdef' },
+ expect.any(Object),
+ );
+ });
+
+ it.each`
+ imgSrc | imgAlt
+ ${'data:image/jpeg;base64,AAAAAA+/'} | ${'my image title'}
+ ${'data:image/jpeg;base64,AAAAAA+/'} | ${'"somebody\'s image" &'}
+ ${'hack" onclick=alert(0)'} | ${'hack" onclick=alert(0)'}
+ ${'hack\\" onclick=alert(0)'} | ${'hack\\" onclick=alert(0)'}
+ ${"hack' onclick=alert(0)"} | ${"hack' onclick=alert(0)"}
+ ${"hack'><script>alert(0)</script>"} | ${"hack'><script>alert(0)</script>"}
+ `(
+ 'transforms template tags with base64 encoded images available locally',
+ ({ imgSrc, imgAlt }) => {
+ createComponent({
+ images: {
+ '{{gl_md_img_1}}': {
+ src: imgSrc,
+ alt: imgAlt,
+ title: imgAlt,
+ },
+ },
+ });
+
+ return waitForPromises().then(() => {
+ const img = wrapper.find('.md-previewer img').element;
+
+ // if the values are the same as the input, it means
+ // they were escaped correctly
+ expect(img).toHaveAttr('src', imgSrc);
+ expect(img).toHaveAttr('alt', imgAlt);
+ expect(img).toHaveAttr('title', imgAlt);
+ });
+ },
+ );
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(500, {
+ body: 'Internal Server Error',
+ });
+ });
+ it('renders an error message if loading the markdown preview fails', () => {
+ createComponent();
+
+ return waitForPromises().then(() => {
+ expect(wrapper.find('.md-previewer').text()).toContain('error');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js
index 3a75ab2d127..98962918b49 100644
--- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js
+++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js
@@ -56,13 +56,8 @@ describe('date time picker lib', () => {
describe('stringToISODate', () => {
['', 'null', undefined, 'abc'].forEach(input => {
- it(`throws error for invalid input like ${input}`, done => {
- try {
- dateTimePickerLib.stringToISODate(input);
- } catch (e) {
- expect(e).toBeDefined();
- done();
- }
+ it(`throws error for invalid input like ${input}`, () => {
+ expect(() => dateTimePickerLib.stringToISODate(input)).toThrow();
});
});
[
diff --git a/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js
new file mode 100644
index 00000000000..636508be6b6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js
@@ -0,0 +1,98 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants';
+import diffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
+
+describe('DiffViewer', () => {
+ const requiredProps = {
+ diffMode: 'replaced',
+ diffViewerMode: 'image',
+ newPath: GREEN_BOX_IMAGE_URL,
+ newSha: 'ABC',
+ oldPath: RED_BOX_IMAGE_URL,
+ oldSha: 'DEF',
+ };
+ let vm;
+
+ function createComponent(props) {
+ const DiffViewer = Vue.extend(diffViewer);
+
+ vm = mountComponent(DiffViewer, props);
+ }
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders image diff', done => {
+ window.gon = {
+ relative_url_root: '',
+ };
+
+ createComponent({ ...requiredProps, projectPath: '' });
+
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe(
+ `//-/raw/DEF/${RED_BOX_IMAGE_URL}`,
+ );
+
+ expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe(
+ `//-/raw/ABC/${GREEN_BOX_IMAGE_URL}`,
+ );
+
+ done();
+ });
+ });
+
+ it('renders fallback download diff display', done => {
+ createComponent({
+ ...requiredProps,
+ diffViewerMode: 'added',
+ newPath: 'test.abc',
+ oldPath: 'testold.abc',
+ });
+
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.deleted .file-info').textContent.trim()).toContain(
+ 'testold.abc',
+ );
+
+ expect(vm.$el.querySelector('.deleted .btn.btn-default').textContent.trim()).toContain(
+ 'Download',
+ );
+
+ expect(vm.$el.querySelector('.added .file-info').textContent.trim()).toContain('test.abc');
+ expect(vm.$el.querySelector('.added .btn.btn-default').textContent.trim()).toContain(
+ 'Download',
+ );
+
+ done();
+ });
+ });
+
+ it('renders renamed component', () => {
+ createComponent({
+ ...requiredProps,
+ diffMode: 'renamed',
+ diffViewerMode: 'renamed',
+ newPath: 'test.abc',
+ oldPath: 'testold.abc',
+ });
+
+ expect(vm.$el.textContent).toContain('File moved');
+ });
+
+ it('renders mode changed component', () => {
+ createComponent({
+ ...requiredProps,
+ diffMode: 'mode_changed',
+ newPath: 'test.abc',
+ oldPath: 'testold.abc',
+ aMode: '123',
+ bMode: '321',
+ });
+
+ expect(vm.$el.textContent).toContain('File mode changed from 123 to 321');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js
new file mode 100644
index 00000000000..892a96b76fd
--- /dev/null
+++ b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js
@@ -0,0 +1,81 @@
+import Vue from 'vue';
+
+import { mountComponentWithSlots } from 'helpers/vue_mount_component_helper';
+import dropdownButtonComponent from '~/vue_shared/components/dropdown/dropdown_button.vue';
+
+const defaultLabel = 'Select';
+const customLabel = 'Select project';
+
+const createComponent = (props, slots = {}) => {
+ const Component = Vue.extend(dropdownButtonComponent);
+
+ return mountComponentWithSlots(Component, { props, slots });
+};
+
+describe('DropdownButtonComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('dropdownToggleText', () => {
+ it('returns default toggle text', () => {
+ expect(vm.toggleText).toBe(defaultLabel);
+ });
+
+ it('returns custom toggle text when provided via props', () => {
+ const vmEmptyLabels = createComponent({ toggleText: customLabel });
+
+ expect(vmEmptyLabels.toggleText).toBe(customLabel);
+ vmEmptyLabels.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element of type `button`', () => {
+ expect(vm.$el.nodeName).toBe('BUTTON');
+ });
+
+ it('renders component container element with required data attributes', () => {
+ expect(vm.$el.dataset.abilityName).toBe(vm.abilityName);
+ expect(vm.$el.dataset.fieldName).toBe(vm.fieldName);
+ expect(vm.$el.dataset.issueUpdate).toBe(vm.updatePath);
+ expect(vm.$el.dataset.labels).toBe(vm.labelsPath);
+ expect(vm.$el.dataset.namespacePath).toBe(vm.namespace);
+ expect(vm.$el.dataset.showAny).not.toBeDefined();
+ });
+
+ it('renders dropdown toggle text element', () => {
+ const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text');
+
+ expect(dropdownToggleTextEl).not.toBeNull();
+ expect(dropdownToggleTextEl.innerText.trim()).toBe(defaultLabel);
+ });
+
+ it('renders dropdown button icon', () => {
+ const dropdownIconEl = vm.$el.querySelector('.dropdown-toggle-icon i.fa');
+
+ expect(dropdownIconEl).not.toBeNull();
+ expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true);
+ });
+
+ it('renders slot, if default slot exists', () => {
+ vm = createComponent(
+ {},
+ {
+ default: ['Lorem Ipsum Dolar'],
+ },
+ );
+
+ expect(vm.$el.querySelector('.dropdown-toggle-text')).toBeNull();
+ expect(vm.$el).toHaveText('Lorem Ipsum Dolar');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js
new file mode 100644
index 00000000000..30b8e869aab
--- /dev/null
+++ b/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import dropdownHiddenInputComponent from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
+
+import { mockLabels } from './mock_data';
+
+const createComponent = (name = 'label_id[]', value = mockLabels[0].id) => {
+ const Component = Vue.extend(dropdownHiddenInputComponent);
+
+ return mountComponent(Component, {
+ name,
+ value,
+ });
+};
+
+describe('DropdownHiddenInputComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('template', () => {
+ it('renders input element of type `hidden`', () => {
+ expect(vm.$el.nodeName).toBe('INPUT');
+ expect(vm.$el.getAttribute('type')).toBe('hidden');
+ expect(vm.$el.getAttribute('name')).toBe(vm.name);
+ expect(vm.$el.getAttribute('value')).toBe(`${vm.value}`);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/dropdown/mock_data.js b/spec/frontend/vue_shared/components/dropdown/mock_data.js
index b09d42da401..b09d42da401 100644
--- a/spec/javascripts/vue_shared/components/dropdown/mock_data.js
+++ b/spec/frontend/vue_shared/components/dropdown/mock_data.js
diff --git a/spec/frontend/vue_shared/components/file_finder/item_spec.js b/spec/frontend/vue_shared/components/file_finder/item_spec.js
new file mode 100644
index 00000000000..63f2614106d
--- /dev/null
+++ b/spec/frontend/vue_shared/components/file_finder/item_spec.js
@@ -0,0 +1,140 @@
+import Vue from 'vue';
+import { file } from 'jest/ide/helpers';
+import ItemComponent from '~/vue_shared/components/file_finder/item.vue';
+import createComponent from 'helpers/vue_mount_component_helper';
+
+describe('File finder item spec', () => {
+ const Component = Vue.extend(ItemComponent);
+ let vm;
+ let localFile;
+
+ beforeEach(() => {
+ localFile = {
+ ...file(),
+ name: 'test file',
+ path: 'test/file',
+ };
+
+ vm = createComponent(Component, {
+ file: localFile,
+ focused: true,
+ searchText: '',
+ index: 0,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders file name & path', () => {
+ expect(vm.$el.textContent).toContain('test file');
+ expect(vm.$el.textContent).toContain('test/file');
+ });
+
+ describe('focused', () => {
+ it('adds is-focused class', () => {
+ expect(vm.$el.classList).toContain('is-focused');
+ });
+
+ it('does not have is-focused class when not focused', done => {
+ vm.focused = false;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.classList).not.toContain('is-focused');
+
+ done();
+ });
+ });
+ });
+
+ describe('changed file icon', () => {
+ it('does not render when not a changed or temp file', () => {
+ expect(vm.$el.querySelector('.diff-changed-stats')).toBe(null);
+ });
+
+ it('renders when a changed file', done => {
+ vm.file.changed = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('renders when a temp file', done => {
+ vm.file.tempFile = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null);
+
+ done();
+ });
+ });
+ });
+
+ it('emits event when clicked', () => {
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+
+ vm.$el.click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('click', vm.file);
+ });
+
+ describe('path', () => {
+ let el;
+
+ beforeEach(done => {
+ vm.searchText = 'file';
+
+ el = vm.$el.querySelector('.diff-changed-file-path');
+
+ vm.$nextTick(done);
+ });
+
+ it('highlights text', () => {
+ expect(el.querySelectorAll('.highlighted').length).toBe(4);
+ });
+
+ it('adds ellipsis to long text', done => {
+ vm.file.path = new Array(70)
+ .fill()
+ .map((_, i) => `${i}-`)
+ .join('');
+
+ vm.$nextTick(() => {
+ expect(el.textContent).toBe(`...${vm.file.path.substr(vm.file.path.length - 60)}`);
+ done();
+ });
+ });
+ });
+
+ describe('name', () => {
+ let el;
+
+ beforeEach(done => {
+ vm.searchText = 'file';
+
+ el = vm.$el.querySelector('.diff-changed-file-name');
+
+ vm.$nextTick(done);
+ });
+
+ it('highlights text', () => {
+ expect(el.querySelectorAll('.highlighted').length).toBe(4);
+ });
+
+ it('does not add ellipsis to long text', done => {
+ vm.file.name = new Array(70)
+ .fill()
+ .map((_, i) => `${i}-`)
+ .join('');
+
+ vm.$nextTick(() => {
+ expect(el.textContent).not.toBe(`...${vm.file.name.substr(vm.file.name.length - 60)}`);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js
index 732491378fa..46df2d2aaf1 100644
--- a/spec/frontend/vue_shared/components/file_row_spec.js
+++ b/spec/frontend/vue_shared/components/file_row_spec.js
@@ -91,9 +91,7 @@ describe('File row component', () => {
jest.spyOn(wrapper.vm, 'scrollIntoView');
wrapper.setProps({
- file: Object.assign({}, wrapper.props('file'), {
- active: true,
- }),
+ file: { ...wrapper.props('file'), active: true },
});
return nextTick().then(() => {
@@ -125,9 +123,7 @@ describe('File row component', () => {
it('matches the current route against encoded file URL', () => {
const fileName = 'with space';
- const rowFile = Object.assign({}, file(fileName), {
- url: `/${fileName}`,
- });
+ const rowFile = { ...file(fileName), url: `/${fileName}` };
const routerPath = `/project/${escapeFileUrl(fileName)}`;
createComponent(
{
diff --git a/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js b/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js
new file mode 100644
index 00000000000..87cafa0bb8c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js
@@ -0,0 +1,190 @@
+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/gl_countdown_spec.js b/spec/frontend/vue_shared/components/gl_countdown_spec.js
new file mode 100644
index 00000000000..365c9fad478
--- /dev/null
+++ b/spec/frontend/vue_shared/components/gl_countdown_spec.js
@@ -0,0 +1,83 @@
+import mountComponent from 'helpers/vue_mount_component_helper';
+import Vue from 'vue';
+import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
+
+describe('GlCountdown', () => {
+ const Component = Vue.extend(GlCountdown);
+ let vm;
+ let now = '2000-01-01T00:00:00Z';
+
+ beforeEach(() => {
+ jest.spyOn(Date, 'now').mockImplementation(() => new Date(now).getTime());
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ jest.clearAllTimers();
+ });
+
+ describe('when there is time remaining', () => {
+ beforeEach(done => {
+ vm = mountComponent(Component, {
+ endDateString: '2000-01-01T01:02:03Z',
+ });
+
+ Vue.nextTick()
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays remaining time', () => {
+ expect(vm.$el.textContent).toContain('01:02:03');
+ });
+
+ it('updates remaining time', done => {
+ now = '2000-01-01T00:00:01Z';
+ jest.advanceTimersByTime(1000);
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el.textContent).toContain('01:02:02');
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('when there is no time remaining', () => {
+ beforeEach(done => {
+ vm = mountComponent(Component, {
+ endDateString: '1900-01-01T00:00:00Z',
+ });
+
+ Vue.nextTick()
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays 00:00:00', () => {
+ expect(vm.$el.textContent).toContain('00:00:00');
+ });
+ });
+
+ describe('when an invalid date is passed', () => {
+ beforeEach(() => {
+ Vue.config.warnHandler = jest.fn();
+ });
+
+ afterEach(() => {
+ Vue.config.warnHandler = null;
+ });
+
+ it('throws a validation error', () => {
+ vm = mountComponent(Component, {
+ endDateString: 'this is invalid',
+ });
+
+ expect(Vue.config.warnHandler).toHaveBeenCalledTimes(1);
+ const [errorMessage] = Vue.config.warnHandler.mock.calls[0];
+
+ expect(errorMessage).toMatch(/^Invalid prop: .* "endDateString"/);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js
new file mode 100644
index 00000000000..216563165d6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js
@@ -0,0 +1,93 @@
+import Vue from 'vue';
+import mountComponent, { mountComponentWithSlots } from 'helpers/vue_mount_component_helper';
+import headerCi from '~/vue_shared/components/header_ci_component.vue';
+
+describe('Header CI Component', () => {
+ let HeaderCi;
+ let vm;
+ let props;
+
+ beforeEach(() => {
+ HeaderCi = Vue.extend(headerCi);
+ props = {
+ status: {
+ group: 'failed',
+ icon: 'status_failed',
+ label: 'failed',
+ text: 'failed',
+ details_path: 'path',
+ },
+ itemName: 'job',
+ itemId: 123,
+ time: '2017-05-08T14:57:39.781Z',
+ user: {
+ web_url: 'path',
+ name: 'Foo',
+ username: 'foobar',
+ email: 'foo@bar.com',
+ avatar_url: 'link',
+ },
+ hasSidebarButton: true,
+ };
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ const findActionButtons = () => vm.$el.querySelector('.header-action-buttons');
+
+ describe('render', () => {
+ beforeEach(() => {
+ vm = mountComponent(HeaderCi, props);
+ });
+
+ it('should render status badge', () => {
+ expect(vm.$el.querySelector('.ci-failed')).toBeDefined();
+ expect(vm.$el.querySelector('.ci-status-icon-failed svg')).toBeDefined();
+ expect(vm.$el.querySelector('.ci-failed').getAttribute('href')).toEqual(
+ props.status.details_path,
+ );
+ });
+
+ it('should render item name and id', () => {
+ expect(vm.$el.querySelector('strong').textContent.trim()).toEqual('job #123');
+ });
+
+ it('should render timeago date', () => {
+ expect(vm.$el.querySelector('time')).toBeDefined();
+ });
+
+ it('should render user icon and name', () => {
+ expect(vm.$el.querySelector('.js-user-link').innerText.trim()).toContain(props.user.name);
+ });
+
+ it('should render sidebar toggle button', () => {
+ expect(vm.$el.querySelector('.js-sidebar-build-toggle')).not.toBeNull();
+ });
+
+ it('should not render header action buttons when empty', () => {
+ expect(findActionButtons()).toBeNull();
+ });
+ });
+
+ describe('slot', () => {
+ it('should render header action buttons', () => {
+ vm = mountComponentWithSlots(HeaderCi, { props, slots: { default: 'Test Actions' } });
+
+ const buttons = findActionButtons();
+
+ expect(buttons).not.toBeNull();
+ expect(buttons.textContent).toEqual('Test Actions');
+ });
+ });
+
+ describe('shouldRenderTriggeredLabel', () => {
+ it('should rendered created keyword when the shouldRenderTriggeredLabel is false', () => {
+ vm = mountComponent(HeaderCi, { ...props, shouldRenderTriggeredLabel: false });
+
+ expect(vm.$el.textContent).toContain('created');
+ expect(vm.$el.textContent).not.toContain('triggered');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/identicon_spec.js b/spec/frontend/vue_shared/components/identicon_spec.js
index 5e8b013d480..53a55dcd6bd 100644
--- a/spec/frontend/vue_shared/components/identicon_spec.js
+++ b/spec/frontend/vue_shared/components/identicon_spec.js
@@ -4,12 +4,17 @@ import IdenticonComponent from '~/vue_shared/components/identicon.vue';
describe('Identicon', () => {
let wrapper;
- const createComponent = () => {
+ const defaultProps = {
+ entityId: 1,
+ entityName: 'entity-name',
+ sizeClass: 's40',
+ };
+
+ const createComponent = (props = {}) => {
wrapper = shallowMount(IdenticonComponent, {
propsData: {
- entityId: 1,
- entityName: 'entity-name',
- sizeClass: 's40',
+ ...defaultProps,
+ ...props,
},
});
};
@@ -19,15 +24,27 @@ describe('Identicon', () => {
wrapper = null;
});
- it('matches snapshot', () => {
- createComponent();
+ describe('entity id is a number', () => {
+ beforeEach(createComponent);
+
+ it('matches snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
- expect(wrapper.element).toMatchSnapshot();
+ it('adds a correct class to identicon', () => {
+ expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2');
+ });
});
- it('adds a correct class to identicon', () => {
- createComponent();
+ describe('entity id is a GraphQL id', () => {
+ beforeEach(() => createComponent({ entityId: 'gid://gitlab/Project/8' }));
+
+ it('matches snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
- expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2');
+ it('adds a correct class to identicon', () => {
+ expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2');
+ });
});
});
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 4c654e01f74..90c3fe54901 100644
--- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js
+++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js
@@ -36,9 +36,7 @@ describe('IssueMilestoneComponent', () => {
describe('isMilestoneStarted', () => {
it('should return `false` when milestoneStart prop is not defined', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- start_date: '',
- }),
+ milestone: { ...mockMilestone, start_date: '' },
});
expect(wrapper.vm.isMilestoneStarted).toBe(false);
@@ -46,9 +44,7 @@ describe('IssueMilestoneComponent', () => {
it('should return `true` when milestone start date is past current date', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- start_date: '1990-07-22',
- }),
+ milestone: { ...mockMilestone, start_date: '1990-07-22' },
});
expect(wrapper.vm.isMilestoneStarted).toBe(true);
@@ -58,9 +54,7 @@ describe('IssueMilestoneComponent', () => {
describe('isMilestonePastDue', () => {
it('should return `false` when milestoneDue prop is not defined', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- due_date: '',
- }),
+ milestone: { ...mockMilestone, due_date: '' },
});
expect(wrapper.vm.isMilestonePastDue).toBe(false);
@@ -68,9 +62,7 @@ describe('IssueMilestoneComponent', () => {
it('should return `true` when milestone due is past current date', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- due_date: '1990-07-22',
- }),
+ milestone: { ...mockMilestone, due_date: '1990-07-22' },
});
expect(wrapper.vm.isMilestonePastDue).toBe(true);
@@ -84,9 +76,7 @@ describe('IssueMilestoneComponent', () => {
it('returns string containing absolute milestone start date when due date is not present', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- due_date: '',
- }),
+ milestone: { ...mockMilestone, due_date: '' },
});
expect(wrapper.vm.milestoneDatesAbsolute).toBe('(January 1, 2018)');
@@ -94,10 +84,7 @@ describe('IssueMilestoneComponent', () => {
it('returns empty string when both milestone start and due dates are not present', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- start_date: '',
- due_date: '',
- }),
+ milestone: { ...mockMilestone, start_date: '', due_date: '' },
});
expect(wrapper.vm.milestoneDatesAbsolute).toBe('');
@@ -107,9 +94,7 @@ describe('IssueMilestoneComponent', () => {
describe('milestoneDatesHuman', () => {
it('returns string containing milestone due date when date is yet to be due', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- due_date: `${new Date().getFullYear() + 10}-01-01`,
- }),
+ milestone: { ...mockMilestone, due_date: `${new Date().getFullYear() + 10}-01-01` },
});
expect(wrapper.vm.milestoneDatesHuman).toContain('years remaining');
@@ -117,10 +102,7 @@ describe('IssueMilestoneComponent', () => {
it('returns string containing milestone start date when date has already started and due date is not present', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- start_date: '1990-07-22',
- due_date: '',
- }),
+ milestone: { ...mockMilestone, start_date: '1990-07-22', due_date: '' },
});
expect(wrapper.vm.milestoneDatesHuman).toContain('Started');
@@ -128,10 +110,11 @@ describe('IssueMilestoneComponent', () => {
it('returns string containing milestone start date when date is yet to start and due date is not present', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
+ milestone: {
+ ...mockMilestone,
start_date: `${new Date().getFullYear() + 10}-01-01`,
due_date: '',
- }),
+ },
});
expect(wrapper.vm.milestoneDatesHuman).toContain('Starts');
@@ -139,10 +122,7 @@ describe('IssueMilestoneComponent', () => {
it('returns empty string when milestone start and due dates are not present', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- start_date: '',
- due_date: '',
- }),
+ milestone: { ...mockMilestone, start_date: '', due_date: '' },
});
expect(wrapper.vm.milestoneDatesHuman).toBe('');
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 f7b1f041ef2..dd24ecf707d 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
@@ -2,10 +2,7 @@ import Vue from 'vue';
import { mount } from '@vue/test-utils';
import { formatDate } from '~/lib/utils/datetime_utility';
import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
-import {
- defaultAssignees,
- defaultMilestone,
-} from '../../../../javascripts/vue_shared/components/issue/related_issuable_mock_data';
+import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data';
describe('RelatedIssuableItem', () => {
let wrapper;
diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js
index 46e269e5071..54ce1f47e28 100644
--- a/spec/frontend/vue_shared/components/markdown/field_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/field_spec.js
@@ -9,9 +9,9 @@ const markdownPreviewPath = `${TEST_HOST}/preview`;
const markdownDocsPath = `${TEST_HOST}/docs`;
function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) {
- expect(writeLink.element.parentNode.classList.contains('active')).toEqual(isWrite);
- expect(previewLink.element.parentNode.classList.contains('active')).toEqual(!isWrite);
- expect(wrapper.find('.md-preview-holder').element.style.display).toEqual(isWrite ? 'none' : '');
+ expect(writeLink.element.parentNode.classList.contains('active')).toBe(isWrite);
+ expect(previewLink.element.parentNode.classList.contains('active')).toBe(!isWrite);
+ expect(wrapper.find('.md-preview-holder').element.style.display).toBe(isWrite ? 'none' : '');
}
function createComponent() {
@@ -67,6 +67,10 @@ describe('Markdown field component', () => {
let previewLink;
let writeLink;
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
it('renders textarea inside backdrop', () => {
wrapper = createComponent();
expect(wrapper.find('.zen-backdrop textarea').element).not.toBeNull();
@@ -92,32 +96,24 @@ describe('Markdown field component', () => {
previewLink = getPreviewLink(wrapper);
previewLink.trigger('click');
- wrapper.vm.$nextTick(() => {
+ return wrapper.vm.$nextTick(() => {
expect(wrapper.find('.md-preview-holder').element.textContent.trim()).toContain(
'Loading…',
);
});
});
- it('renders markdown preview', () => {
+ it('renders markdown preview and GFM', () => {
wrapper = createComponent();
- previewLink = getPreviewLink(wrapper);
- previewLink.trigger('click');
+ const renderGFMSpy = jest.spyOn($.fn, 'renderGFM');
- setTimeout(() => {
- expect(wrapper.find('.md-preview-holder').element.innerHTML).toContain(previewHTML);
- });
- });
-
- it('renders GFM with jQuery', () => {
- wrapper = createComponent();
previewLink = getPreviewLink(wrapper);
- jest.spyOn($.fn, 'renderGFM');
previewLink.trigger('click');
return axios.waitFor(markdownPreviewPath).then(() => {
expect(wrapper.find('.md-preview-holder').element.innerHTML).toContain(previewHTML);
+ expect(renderGFMSpy).toHaveBeenCalled();
});
});
@@ -176,7 +172,7 @@ describe('Markdown field component', () => {
const markdownButton = getMarkdownButton(wrapper);
markdownButton.trigger('click');
- wrapper.vm.$nextTick(() => {
+ return wrapper.vm.$nextTick(() => {
expect(textarea.value).toContain('**testing**');
});
});
@@ -188,8 +184,8 @@ describe('Markdown field component', () => {
const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5];
markdownButton.trigger('click');
- wrapper.vm.$nextTick(() => {
- expect(textarea.value).toContain('* testing');
+ return wrapper.vm.$nextTick(() => {
+ expect(textarea.value).toContain('* testing');
});
});
@@ -200,7 +196,7 @@ describe('Markdown field component', () => {
const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5];
markdownButton.trigger('click');
- wrapper.vm.$nextTick(() => {
+ return wrapper.vm.$nextTick(() => {
expect(textarea.value).toContain('* testing\n* 123');
});
});
diff --git a/spec/frontend/vue_shared/components/markdown/field_view_spec.js b/spec/frontend/vue_shared/components/markdown/field_view_spec.js
new file mode 100644
index 00000000000..80cf1f655c6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/markdown/field_view_spec.js
@@ -0,0 +1,26 @@
+import $ from 'jquery';
+import { shallowMount } from '@vue/test-utils';
+
+import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
+
+describe('Markdown Field View component', () => {
+ let renderGFMSpy;
+ let wrapper;
+
+ function createComponent() {
+ wrapper = shallowMount(MarkdownFieldView);
+ }
+
+ beforeEach(() => {
+ renderGFMSpy = jest.spyOn($.fn, 'renderGFM');
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('processes rendering with GFM', () => {
+ expect(renderGFMSpy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/markdown/suggestions_spec.js b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js
new file mode 100644
index 00000000000..34ccdf38b00
--- /dev/null
+++ b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js
@@ -0,0 +1,102 @@
+import Vue from 'vue';
+import SuggestionsComponent from '~/vue_shared/components/markdown/suggestions.vue';
+
+const MOCK_DATA = {
+ suggestions: [
+ {
+ id: 1,
+ appliable: true,
+ applied: false,
+ current_user: {
+ can_apply: true,
+ },
+ diff_lines: [
+ {
+ can_receive_suggestion: false,
+ line_code: null,
+ meta_data: null,
+ new_line: null,
+ old_line: 5,
+ rich_text: '-test',
+ text: '-test',
+ type: 'old',
+ },
+ {
+ can_receive_suggestion: true,
+ line_code: null,
+ meta_data: null,
+ new_line: 5,
+ old_line: null,
+ rich_text: '+new test',
+ text: '+new test',
+ type: 'new',
+ },
+ ],
+ },
+ ],
+ noteHtml: `
+ <div class="suggestion">
+ <div class="line">-oldtest</div>
+ </div>
+ <div class="suggestion">
+ <div class="line">+newtest</div>
+ </div>
+ `,
+ isApplied: false,
+ helpPagePath: 'path_to_docs',
+};
+
+describe('Suggestion component', () => {
+ let vm;
+ let diffTable;
+
+ beforeEach(done => {
+ const Component = Vue.extend(SuggestionsComponent);
+
+ vm = new Component({
+ propsData: MOCK_DATA,
+ }).$mount();
+
+ diffTable = vm.generateDiff(0).$mount().$el;
+
+ jest.spyOn(vm, 'renderSuggestions').mockImplementation(() => {});
+ vm.renderSuggestions();
+ Vue.nextTick(done);
+ });
+
+ describe('mounted', () => {
+ it('renders a flash container', () => {
+ expect(vm.$el.querySelector('.js-suggestions-flash')).not.toBeNull();
+ });
+
+ it('renders a container for suggestions', () => {
+ expect(vm.$refs.container).not.toBeNull();
+ });
+
+ it('renders suggestions', () => {
+ expect(vm.renderSuggestions).toHaveBeenCalled();
+ expect(vm.$el.innerHTML.includes('oldtest')).toBe(true);
+ expect(vm.$el.innerHTML.includes('newtest')).toBe(true);
+ });
+ });
+
+ describe('generateDiff', () => {
+ it('generates a diff table', () => {
+ expect(diffTable.querySelector('.md-suggestion-diff')).not.toBeNull();
+ });
+
+ it('generates a diff table that contains contents the suggested lines', () => {
+ MOCK_DATA.suggestions[0].diff_lines.forEach(line => {
+ const text = line.text.substring(1);
+
+ expect(diffTable.innerHTML.includes(text)).toBe(true);
+ });
+ });
+
+ it('generates a diff table with the correct line number for each suggested line', () => {
+ const lines = diffTable.querySelectorAll('.old_line');
+
+ expect(parseInt([...lines][0].innerHTML, 10)).toBe(5);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
new file mode 100644
index 00000000000..e7c31014bfc
--- /dev/null
+++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import toolbar from '~/vue_shared/components/markdown/toolbar.vue';
+
+describe('toolbar', () => {
+ let vm;
+ const Toolbar = Vue.extend(toolbar);
+ const props = {
+ markdownDocsPath: '',
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('user can attach file', () => {
+ beforeEach(() => {
+ vm = mountComponent(Toolbar, props);
+ });
+
+ it('should render uploading-container', () => {
+ expect(vm.$el.querySelector('.uploading-container')).not.toBeNull();
+ });
+ });
+
+ describe('user cannot attach file', () => {
+ beforeEach(() => {
+ vm = mountComponent(Toolbar, { ...props, canAttachFile: false });
+ });
+
+ it('should not render uploading-container', () => {
+ expect(vm.$el.querySelector('.uploading-container')).toBeNull();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/navigation_tabs_spec.js b/spec/frontend/vue_shared/components/navigation_tabs_spec.js
new file mode 100644
index 00000000000..561456d614e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/navigation_tabs_spec.js
@@ -0,0 +1,64 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import navigationTabs from '~/vue_shared/components/navigation_tabs.vue';
+
+describe('navigation tabs component', () => {
+ let vm;
+ let Component;
+ let data;
+
+ beforeEach(() => {
+ data = [
+ {
+ name: 'All',
+ scope: 'all',
+ count: 1,
+ isActive: true,
+ },
+ {
+ name: 'Pending',
+ scope: 'pending',
+ count: 0,
+ isActive: false,
+ },
+ {
+ name: 'Running',
+ scope: 'running',
+ isActive: false,
+ },
+ ];
+
+ Component = Vue.extend(navigationTabs);
+ vm = mountComponent(Component, { tabs: data, scope: 'pipelines' });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render tabs', () => {
+ expect(vm.$el.querySelectorAll('li').length).toEqual(data.length);
+ });
+
+ it('should render active tab', () => {
+ expect(vm.$el.querySelector('.active .js-pipelines-tab-all')).toBeDefined();
+ });
+
+ it('should render badge', () => {
+ expect(vm.$el.querySelector('.js-pipelines-tab-all .badge').textContent.trim()).toEqual('1');
+ expect(vm.$el.querySelector('.js-pipelines-tab-pending .badge').textContent.trim()).toEqual(
+ '0',
+ );
+ });
+
+ it('should not render badge', () => {
+ expect(vm.$el.querySelector('.js-pipelines-tab-running .badge')).toEqual(null);
+ });
+
+ it('should trigger onTabClick', () => {
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ vm.$el.querySelector('.js-pipelines-tab-pending').click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('onChangeTab', 'pending');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/pikaday_spec.js b/spec/frontend/vue_shared/components/pikaday_spec.js
new file mode 100644
index 00000000000..867bf88ff50
--- /dev/null
+++ b/spec/frontend/vue_shared/components/pikaday_spec.js
@@ -0,0 +1,30 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import datePicker from '~/vue_shared/components/pikaday.vue';
+
+describe('datePicker', () => {
+ let vm;
+ beforeEach(() => {
+ const DatePicker = Vue.extend(datePicker);
+ vm = mountComponent(DatePicker, {
+ label: 'label',
+ });
+ });
+
+ it('should render label text', () => {
+ expect(vm.$el.querySelector('.dropdown-toggle-text').innerText.trim()).toEqual('label');
+ });
+
+ it('should show calendar', () => {
+ expect(vm.$el.querySelector('.pika-single')).toBeDefined();
+ });
+
+ it('should toggle when dropdown is clicked', () => {
+ const hidePicker = jest.fn();
+ vm.$on('hidePicker', hidePicker);
+
+ vm.$el.querySelector('.dropdown-menu-toggle').click();
+
+ expect(hidePicker).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/project_avatar/default_spec.js b/spec/frontend/vue_shared/components/project_avatar/default_spec.js
new file mode 100644
index 00000000000..090f8b69213
--- /dev/null
+++ b/spec/frontend/vue_shared/components/project_avatar/default_spec.js
@@ -0,0 +1,58 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import { projectData } from 'jest/ide/mock_data';
+import { TEST_HOST } from 'spec/test_constants';
+import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility';
+import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue';
+
+describe('ProjectAvatarDefault component', () => {
+ const Component = Vue.extend(ProjectAvatarDefault);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ project: projectData,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders identicon if project has no avatar_url', done => {
+ const expectedText = getFirstCharacterCapitalized(projectData.name);
+
+ vm.project = {
+ ...vm.project,
+ avatar_url: null,
+ };
+
+ vm.$nextTick()
+ .then(() => {
+ const identiconEl = vm.$el.querySelector('.identicon');
+
+ expect(identiconEl).not.toBe(null);
+ expect(identiconEl.textContent.trim()).toEqual(expectedText);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders avatar image if project has avatar_url', done => {
+ const avatarUrl = `${TEST_HOST}/images/home/nasa.svg`;
+
+ vm.project = {
+ ...vm.project,
+ avatar_url: avatarUrl,
+ };
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.avatar')).not.toBeNull();
+ expect(vm.$el.querySelector('.identicon')).toBeNull();
+ expect(vm.$el.querySelector('img')).toHaveAttr('src', avatarUrl);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+});
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
new file mode 100644
index 00000000000..eb1d9e93634
--- /dev/null
+++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js
@@ -0,0 +1,109 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { trimText } from 'helpers/text_helper';
+import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
+
+const localVue = createLocalVue();
+
+describe('ProjectListItem component', () => {
+ const Component = localVue.extend(ProjectListItem);
+ let wrapper;
+ let vm;
+ let options;
+
+ const project = getJSONFixture('static/projects.json')[0];
+
+ beforeEach(() => {
+ options = {
+ propsData: {
+ project,
+ selected: false,
+ },
+ localVue,
+ };
+ });
+
+ afterEach(() => {
+ wrapper.vm.$destroy();
+ });
+
+ 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);
+ });
+
+ it('renders a check mark icon if selected === true', () => {
+ options.propsData.selected = true;
+
+ wrapper = shallowMount(Component, options);
+
+ expect(wrapper.contains('.js-selected-icon.js-selected')).toBe(true);
+ });
+
+ it(`emits a "clicked" event when clicked`, () => {
+ wrapper = shallowMount(Component, options);
+ ({ vm } = wrapper);
+
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ wrapper.vm.onClick();
+
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('click');
+ });
+
+ it(`renders the project avatar`, () => {
+ wrapper = shallowMount(Component, options);
+
+ expect(wrapper.contains('.js-project-avatar')).toBe(true);
+ });
+
+ it(`renders a simple namespace name with a trailing slash`, () => {
+ options.propsData.project.name_with_namespace = 'a / b';
+
+ wrapper = shallowMount(Component, options);
+ const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
+
+ expect(renderedNamespace).toBe('a /');
+ });
+
+ it(`renders a properly truncated namespace with a trailing slash`, () => {
+ options.propsData.project.name_with_namespace = 'a / b / c / d / e / f';
+
+ wrapper = shallowMount(Component, options);
+ const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
+
+ expect(renderedNamespace).toBe('a / ... / e /');
+ });
+
+ it(`renders the project name`, () => {
+ options.propsData.project.name = 'my-test-project';
+
+ wrapper = shallowMount(Component, options);
+ const renderedName = trimText(wrapper.find('.js-project-name').text());
+
+ expect(renderedName).toBe('my-test-project');
+ });
+
+ it(`renders the project name with highlighting in the case of a search query match`, () => {
+ options.propsData.project.name = 'my-test-project';
+ options.propsData.matcher = 'pro';
+
+ wrapper = shallowMount(Component, options);
+ const renderedName = trimText(wrapper.find('.js-project-name').html());
+ const expected = 'my-test-<b>p</b><b>r</b><b>o</b>ject';
+
+ expect(renderedName).toContain(expected);
+ });
+
+ it('prevents search query and project name XSS', () => {
+ const alertSpy = jest.spyOn(window, 'alert');
+ options.propsData.project.name = "my-xss-pro<script>alert('XSS');</script>ject";
+ options.propsData.matcher = "pro<script>alert('XSS');</script>";
+
+ wrapper = shallowMount(Component, options);
+ const renderedName = trimText(wrapper.find('.js-project-name').html());
+ const expected = 'my-xss-project';
+
+ expect(renderedName).toContain(expected);
+ expect(alertSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js
new file mode 100644
index 00000000000..29bced394dc
--- /dev/null
+++ b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js
@@ -0,0 +1,112 @@
+import Vue from 'vue';
+import { head } from 'lodash';
+
+import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui';
+import { mount, createLocalVue } from '@vue/test-utils';
+import { trimText } from 'helpers/text_helper';
+import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
+import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
+
+const localVue = createLocalVue();
+
+describe('ProjectSelector component', () => {
+ let wrapper;
+ let vm;
+ const allProjects = getJSONFixture('static/projects.json');
+ const searchResults = allProjects.slice(0, 5);
+ let selected = [];
+ selected = selected.concat(allProjects.slice(0, 3)).concat(allProjects.slice(5, 8));
+
+ const findSearchInput = () => wrapper.find(GlSearchBoxByType).find('input');
+
+ beforeEach(() => {
+ wrapper = mount(Vue.extend(ProjectSelector), {
+ localVue,
+ propsData: {
+ projectSearchResults: searchResults,
+ selectedProjects: selected,
+ showNoResultsMessage: false,
+ showMinimumSearchQueryMessage: false,
+ showLoadingIndicator: false,
+ showSearchErrorMessage: false,
+ },
+ attachToDocument: true,
+ });
+
+ ({ vm } = wrapper);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders the search results', () => {
+ expect(wrapper.findAll('.js-project-list-item').length).toBe(5);
+ });
+
+ it(`triggers a search when the search input value changes`, () => {
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ const query = 'my test query!';
+ const searchInput = findSearchInput();
+
+ searchInput.setValue(query);
+ searchInput.trigger('input');
+
+ expect(vm.$emit).toHaveBeenCalledWith('searched', query);
+ });
+
+ it(`includes a placeholder in the search box`, () => {
+ const searchInput = findSearchInput();
+
+ expect(searchInput.attributes('placeholder')).toBe('Search your projects');
+ });
+
+ it(`triggers a "bottomReached" event when user has scrolled to the bottom of the list`, () => {
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ wrapper.find(GlInfiniteScroll).vm.$emit('bottomReached');
+
+ expect(vm.$emit).toHaveBeenCalledWith('bottomReached');
+ });
+
+ it(`triggers a "projectClicked" event when a project is clicked`, () => {
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ wrapper.find(ProjectListItem).vm.$emit('click', head(searchResults));
+
+ expect(vm.$emit).toHaveBeenCalledWith('projectClicked', head(searchResults));
+ });
+
+ it(`shows a "no results" message if showNoResultsMessage === true`, () => {
+ wrapper.setProps({ showNoResultsMessage: true });
+
+ return vm.$nextTick().then(() => {
+ const noResultsEl = wrapper.find('.js-no-results-message');
+
+ expect(noResultsEl.exists()).toBe(true);
+ expect(trimText(noResultsEl.text())).toEqual('Sorry, no projects matched your search');
+ });
+ });
+
+ it(`shows a "minimum search query" message if showMinimumSearchQueryMessage === true`, () => {
+ wrapper.setProps({ showMinimumSearchQueryMessage: true });
+
+ return vm.$nextTick().then(() => {
+ const minimumSearchEl = wrapper.find('.js-minimum-search-query-message');
+
+ expect(minimumSearchEl.exists()).toBe(true);
+ expect(trimText(minimumSearchEl.text())).toEqual('Enter at least three characters to search');
+ });
+ });
+
+ it(`shows a error message if showSearchErrorMessage === true`, () => {
+ wrapper.setProps({ showSearchErrorMessage: true });
+
+ return vm.$nextTick().then(() => {
+ const errorMessageEl = wrapper.find('.js-search-error-message');
+
+ expect(errorMessageEl.exists()).toBe(true);
+ expect(trimText(errorMessageEl.text())).toEqual(
+ 'Something went wrong, unable to search projects',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js
new file mode 100644
index 00000000000..549d89171c6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
+import {
+ EDITOR_OPTIONS,
+ EDITOR_TYPES,
+ EDITOR_HEIGHT,
+ EDITOR_PREVIEW_STYLE,
+} from '~/vue_shared/components/rich_content_editor/constants';
+
+describe('Rich Content Editor', () => {
+ let wrapper;
+
+ const value = '## Some Markdown';
+ const findEditor = () => wrapper.find({ ref: 'editor' });
+
+ beforeEach(() => {
+ wrapper = shallowMount(RichContentEditor, {
+ propsData: { value },
+ });
+ });
+
+ describe('when content is loaded', () => {
+ it('renders an editor', () => {
+ expect(findEditor().exists()).toBe(true);
+ });
+
+ it('renders the correct content', () => {
+ expect(findEditor().props().initialValue).toBe(value);
+ });
+
+ it('provides the correct editor options', () => {
+ expect(findEditor().props().options).toEqual(EDITOR_OPTIONS);
+ });
+
+ it('has the correct preview style', () => {
+ expect(findEditor().props().previewStyle).toBe(EDITOR_PREVIEW_STYLE);
+ });
+
+ it('has the correct initial edit type', () => {
+ expect(findEditor().props().initialEditType).toBe(EDITOR_TYPES.wysiwyg);
+ });
+
+ it('has the correct height', () => {
+ expect(findEditor().props().height).toBe(EDITOR_HEIGHT);
+ });
+ });
+
+ describe('when content is changed', () => {
+ it('emits an input event with the changed content', () => {
+ const changedMarkdown = '## Changed Markdown';
+ const getMarkdownMock = jest.fn().mockReturnValueOnce(changedMarkdown);
+
+ findEditor().setMethods({ invoke: getMarkdownMock });
+ findEditor().vm.$emit('change');
+
+ expect(wrapper.emitted().input[0][0]).toBe(changedMarkdown);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js
new file mode 100644
index 00000000000..8545c43dc1e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js
@@ -0,0 +1,44 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import ToolbarItem from '~/vue_shared/components/rich_content_editor/toolbar_item.vue';
+
+describe('Toolbar Item', () => {
+ let wrapper;
+
+ const findIcon = () => wrapper.find(GlIcon);
+ const findButton = () => wrapper.find('button');
+
+ const buildWrapper = propsData => {
+ wrapper = shallowMount(ToolbarItem, { propsData });
+ };
+
+ describe.each`
+ icon
+ ${'heading'}
+ ${'bold'}
+ ${'italic'}
+ ${'strikethrough'}
+ ${'quote'}
+ ${'link'}
+ ${'doc-code'}
+ ${'list-bulleted'}
+ ${'list-numbered'}
+ ${'list-task'}
+ ${'list-indent'}
+ ${'list-outdent'}
+ ${'dash'}
+ ${'table'}
+ ${'code'}
+ `('toolbar item component', ({ icon }) => {
+ beforeEach(() => buildWrapper({ icon }));
+
+ it('renders a toolbar button', () => {
+ expect(findButton().exists()).toBe(true);
+ });
+
+ it(`renders the ${icon} icon`, () => {
+ expect(findIcon().exists()).toBe(true);
+ expect(findIcon().props().name).toBe(icon);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js
new file mode 100644
index 00000000000..7605cc6a22c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js
@@ -0,0 +1,29 @@
+import { generateToolbarItem } from '~/vue_shared/components/rich_content_editor/toolbar_service';
+
+describe('Toolbar Service', () => {
+ const config = {
+ icon: 'bold',
+ command: 'some-command',
+ tooltip: 'Some Tooltip',
+ event: 'some-event',
+ };
+ const generatedItem = generateToolbarItem(config);
+
+ it('generates the correct command', () => {
+ expect(generatedItem.options.command).toBe(config.command);
+ });
+
+ it('generates the correct tooltip', () => {
+ expect(generatedItem.options.tooltip).toBe(config.tooltip);
+ });
+
+ it('generates the correct event', () => {
+ expect(generatedItem.options.event).toBe(config.event);
+ });
+
+ it('generates a divider when isDivider is set to true', () => {
+ const isDivider = true;
+
+ expect(generateToolbarItem({ isDivider })).toBe('divider');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js
index d90fafb6bf7..9db86fa775f 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js
@@ -4,10 +4,7 @@ import { shallowMount } from '@vue/test-utils';
import LabelsSelect from '~/labels_select';
import BaseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue';
-import {
- mockConfig,
- mockLabels,
-} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data';
+import { mockConfig, mockLabels } from './mock_data';
const createComponent = (config = mockConfig) =>
shallowMount(BaseComponent, {
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js
index e2e11c94c0d..d02d924bd2b 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js
@@ -3,16 +3,14 @@ import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue';
-import {
- mockConfig,
- mockLabels,
-} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data';
+import { mockConfig, mockLabels } from './mock_data';
-const componentConfig = Object.assign({}, mockConfig, {
+const componentConfig = {
+ ...mockConfig,
fieldName: 'label_id[]',
labels: mockLabels,
showExtraOptions: false,
-});
+};
const createComponent = (config = componentConfig) => {
const Component = Vue.extend(dropdownButtonComponent);
@@ -34,7 +32,7 @@ describe('DropdownButtonComponent', () => {
describe('computed', () => {
describe('dropdownToggleText', () => {
it('returns text as `Label` when `labels` prop is empty array', () => {
- const mockEmptyLabels = Object.assign({}, componentConfig, { labels: [] });
+ const mockEmptyLabels = { ...componentConfig, labels: [] };
const vmEmptyLabels = createComponent(mockEmptyLabels);
expect(vmEmptyLabels.dropdownToggleText).toBe('Label');
@@ -42,9 +40,7 @@ describe('DropdownButtonComponent', () => {
});
it('returns first label name with remaining label count when `labels` prop has more than one item', () => {
- const mockMoreLabels = Object.assign({}, componentConfig, {
- labels: mockLabels.concat(mockLabels),
- });
+ const mockMoreLabels = { ...componentConfig, labels: mockLabels.concat(mockLabels) };
const vmMoreLabels = createComponent(mockMoreLabels);
expect(vmMoreLabels.dropdownToggleText).toBe(
@@ -54,9 +50,7 @@ describe('DropdownButtonComponent', () => {
});
it('returns first label name when `labels` prop has only one item present', () => {
- const singleLabel = Object.assign({}, componentConfig, {
- labels: [mockLabels[0]],
- });
+ const singleLabel = { ...componentConfig, labels: [mockLabels[0]] };
const vmSingleLabel = createComponent(singleLabel);
expect(vmSingleLabel.dropdownToggleText).toBe(mockLabels[0].title);
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 d0299523137..edec3b138b3 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
@@ -3,7 +3,7 @@ import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue';
-import { mockSuggestedColors } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data';
+import { mockSuggestedColors } from './mock_data';
const createComponent = headerTitle => {
const Component = Vue.extend(dropdownCreateLabelComponent);
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js
index 784bbaf8e6a..7e9e242a4f5 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue';
-import { mockConfig } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data';
+import { mockConfig } from './mock_data';
const createComponent = (
labelsWebUrl = mockConfig.labelsWebUrl,
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 887c04268d1..e09f0006359 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
@@ -3,7 +3,7 @@ import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
-import { mockLabels } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data';
+import { mockLabels } from './mock_data';
const createComponent = (labels = mockLabels) => {
const Component = Vue.extend(dropdownValueCollapsedComponent);
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js
index 06355c0dd65..c33cffb421d 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js
@@ -2,10 +2,7 @@ import { mount } from '@vue/test-utils';
import DropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue';
import { GlLabel } from '@gitlab/ui';
-import {
- mockConfig,
- mockLabels,
-} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data';
+import { mockConfig, mockLabels } from './mock_data';
const createComponent = (
labels = mockLabels,
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js
index 6564c012e67..6564c012e67 100644
--- a/spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js
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 e2d31a41e82..214eb239432 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
@@ -33,9 +33,32 @@ describe('DropdownButton', () => {
wrapper.destroy();
});
+ describe('methods', () => {
+ describe('handleButtonClick', () => {
+ it('calls action `toggleDropdownContents` and stops event propagation when `state.variant` is "standalone"', () => {
+ const event = {
+ stopPropagation: jest.fn(),
+ };
+ wrapper = createComponent({
+ ...mockConfig,
+ variant: 'standalone',
+ });
+
+ jest.spyOn(wrapper.vm, 'toggleDropdownContents');
+
+ wrapper.vm.handleButtonClick(event);
+
+ expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
+ expect(event.stopPropagation).toHaveBeenCalled();
+
+ wrapper.destroy();
+ });
+ });
+ });
+
describe('template', () => {
it('renders component container element', () => {
- expect(wrapper.is('gl-deprecated-button-stub')).toBe(true);
+ expect(wrapper.is('gl-button-stub')).toBe(true);
});
it('renders button text element', () => {
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js
index d7ca7ce30a9..04320a72be6 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js
@@ -1,7 +1,7 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlDeprecatedButton, GlIcon, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue';
import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
@@ -127,12 +127,12 @@ describe('DropdownContentsCreateView', () => {
it('renders dropdown back button element', () => {
const backBtnEl = wrapper
.find('.dropdown-title')
- .findAll(GlDeprecatedButton)
+ .findAll(GlButton)
.at(0);
expect(backBtnEl.exists()).toBe(true);
expect(backBtnEl.attributes('aria-label')).toBe('Go back');
- expect(backBtnEl.find(GlIcon).props('name')).toBe('arrow-left');
+ expect(backBtnEl.props('icon')).toBe('arrow-left');
});
it('renders dropdown title element', () => {
@@ -145,12 +145,12 @@ describe('DropdownContentsCreateView', () => {
it('renders dropdown close button element', () => {
const closeBtnEl = wrapper
.find('.dropdown-title')
- .findAll(GlDeprecatedButton)
+ .findAll(GlButton)
.at(1);
expect(closeBtnEl.exists()).toBe(true);
expect(closeBtnEl.attributes('aria-label')).toBe('Close');
- expect(closeBtnEl.find(GlIcon).props('name')).toBe('close');
+ expect(closeBtnEl.props('icon')).toBe('close');
});
it('renders label title input element', () => {
@@ -192,7 +192,7 @@ describe('DropdownContentsCreateView', () => {
it('renders create button element', () => {
const createBtnEl = wrapper
.find('.dropdown-actions')
- .findAll(GlDeprecatedButton)
+ .findAll(GlButton)
.at(0);
expect(createBtnEl.exists()).toBe(true);
@@ -213,7 +213,7 @@ describe('DropdownContentsCreateView', () => {
it('renders cancel button element', () => {
const cancelBtnEl = wrapper
.find('.dropdown-actions')
- .findAll(GlDeprecatedButton)
+ .findAll(GlButton)
.at(1);
expect(cancelBtnEl.exists()).toBe(true);
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
index 3e6dbdb7ecb..74c769f86a3 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
@@ -1,9 +1,10 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlDeprecatedButton, GlLoadingIcon, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue';
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue';
import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state';
import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations';
@@ -41,13 +42,19 @@ const createComponent = (initialState = mockConfig) => {
describe('DropdownContentsLabelsView', () => {
let wrapper;
+ let wrapperStandalone;
beforeEach(() => {
wrapper = createComponent();
+ wrapperStandalone = createComponent({
+ ...mockConfig,
+ variant: 'standalone',
+ });
});
afterEach(() => {
wrapper.destroy();
+ wrapperStandalone.destroy();
});
describe('computed', () => {
@@ -72,16 +79,6 @@ describe('DropdownContentsLabelsView', () => {
});
describe('methods', () => {
- describe('getDropdownLabelBoxStyle', () => {
- it('returns an object containing `backgroundColor` based on provided `label` param', () => {
- expect(wrapper.vm.getDropdownLabelBoxStyle(mockRegularLabel)).toEqual(
- expect.objectContaining({
- backgroundColor: mockRegularLabel.color,
- }),
- );
- });
- });
-
describe('isLabelSelected', () => {
it('returns true when provided `label` param is one of the selected labels', () => {
expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true);
@@ -165,13 +162,24 @@ describe('DropdownContentsLabelsView', () => {
});
describe('handleLabelClick', () => {
- it('calls action `updateSelectedLabels` with provided `label` param', () => {
+ beforeEach(() => {
jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
+ });
+ it('calls action `updateSelectedLabels` with provided `label` param', () => {
wrapper.vm.handleLabelClick(mockRegularLabel);
expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]);
});
+
+ it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => {
+ jest.spyOn(wrapper.vm, 'toggleDropdownContents');
+ wrapper.vm.$store.state.allowMultiselect = false;
+
+ wrapper.vm.handleLabelClick(mockRegularLabel);
+
+ expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
+ });
});
});
@@ -198,12 +206,15 @@ describe('DropdownContentsLabelsView', () => {
expect(titleEl.text()).toBe('Assign labels');
});
+ it('does not render dropdown title element when `state.variant` is "standalone"', () => {
+ expect(wrapperStandalone.find('.dropdown-title').exists()).toBe(false);
+ });
+
it('renders dropdown close button element', () => {
- const closeButtonEl = wrapper.find('.dropdown-title').find(GlDeprecatedButton);
+ const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton);
expect(closeButtonEl.exists()).toBe(true);
- expect(closeButtonEl.find(GlIcon).exists()).toBe(true);
- expect(closeButtonEl.find(GlIcon).props('name')).toBe('close');
+ expect(closeButtonEl.props('icon')).toBe('close');
});
it('renders label search input element', () => {
@@ -214,16 +225,7 @@ describe('DropdownContentsLabelsView', () => {
});
it('renders label elements for all labels', () => {
- const labelsEl = wrapper.findAll('.dropdown-content li');
- const labelItemEl = labelsEl.at(0).find(GlLink);
-
- expect(labelsEl.length).toBe(mockLabels.length);
- expect(labelItemEl.exists()).toBe(true);
- expect(labelItemEl.find(GlIcon).props('name')).toBe('mobile-issue-close');
- expect(labelItemEl.find('.dropdown-label-box').attributes('style')).toBe(
- 'background-color: rgb(186, 218, 85);',
- );
- expect(labelItemEl.find(GlLink).text()).toContain(mockLabels[0].title);
+ expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length);
});
it('renders label element with "is-focused" when value of `currentHighlightItem` is more than -1', () => {
@@ -233,9 +235,9 @@ describe('DropdownContentsLabelsView', () => {
return wrapper.vm.$nextTick(() => {
const labelsEl = wrapper.findAll('.dropdown-content li');
- const labelItemEl = labelsEl.at(0).find(GlLink);
+ const labelItemEl = labelsEl.at(0).find(LabelItem);
- expect(labelItemEl.attributes('class')).toContain('is-focused');
+ expect(labelItemEl.props('highlight')).toBe(true);
});
});
@@ -247,19 +249,42 @@ describe('DropdownContentsLabelsView', () => {
return wrapper.vm.$nextTick(() => {
const noMatchEl = wrapper.find('.dropdown-content li');
- expect(noMatchEl.exists()).toBe(true);
+ expect(noMatchEl.isVisible()).toBe(true);
expect(noMatchEl.text()).toContain('No matching results');
});
});
it('renders footer list items', () => {
- const createLabelBtn = wrapper.find('.dropdown-footer').find(GlDeprecatedButton);
- const manageLabelsLink = wrapper.find('.dropdown-footer').find(GlLink);
-
- expect(createLabelBtn.exists()).toBe(true);
- expect(createLabelBtn.text()).toBe('Create label');
+ const createLabelLink = wrapper
+ .find('.dropdown-footer')
+ .findAll(GlLink)
+ .at(0);
+ const manageLabelsLink = wrapper
+ .find('.dropdown-footer')
+ .findAll(GlLink)
+ .at(1);
+
+ expect(createLabelLink.exists()).toBe(true);
+ expect(createLabelLink.text()).toBe('Create label');
expect(manageLabelsLink.exists()).toBe(true);
expect(manageLabelsLink.text()).toBe('Manage labels');
});
+
+ it('does not render "Create label" footer link when `state.allowLabelCreate` is `false`', () => {
+ wrapper.vm.$store.state.allowLabelCreate = false;
+
+ return wrapper.vm.$nextTick(() => {
+ const createLabelLink = wrapper
+ .find('.dropdown-footer')
+ .findAll(GlLink)
+ .at(0);
+
+ expect(createLabelLink.text()).not.toBe('Create label');
+ });
+ });
+
+ it('does not render footer list items when `state.variant` is "standalone"', () => {
+ expect(wrapperStandalone.find('.dropdown-footer').exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js
new file mode 100644
index 00000000000..401d208da5c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js
@@ -0,0 +1,111 @@
+import { shallowMount } from '@vue/test-utils';
+
+import { GlIcon, GlLink } from '@gitlab/ui';
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue';
+import { mockRegularLabel } from './mock_data';
+
+const createComponent = ({ label = mockRegularLabel, highlight = true } = {}) =>
+ shallowMount(LabelItem, {
+ propsData: {
+ label,
+ highlight,
+ },
+ });
+
+describe('LabelItem', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('computed', () => {
+ describe('labelBoxStyle', () => {
+ it('returns an object containing `backgroundColor` based on `label` prop', () => {
+ expect(wrapper.vm.labelBoxStyle).toEqual(
+ expect.objectContaining({
+ backgroundColor: mockRegularLabel.color,
+ }),
+ );
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('handleClick', () => {
+ it('sets value of `isSet` data prop to opposite of its current value', () => {
+ wrapper.setData({
+ isSet: true,
+ });
+
+ wrapper.vm.handleClick();
+ expect(wrapper.vm.isSet).toBe(false);
+ wrapper.vm.handleClick();
+ expect(wrapper.vm.isSet).toBe(true);
+ });
+
+ it('emits event `clickLabel` on component with `label` prop as param', () => {
+ wrapper.vm.handleClick();
+
+ expect(wrapper.emitted('clickLabel')).toBeTruthy();
+ expect(wrapper.emitted('clickLabel')[0]).toEqual([mockRegularLabel]);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders gl-link component', () => {
+ expect(wrapper.find(GlLink).exists()).toBe(true);
+ });
+
+ it('renders gl-link component with class `is-focused` when `highlight` prop is true', () => {
+ wrapper.setProps({
+ highlight: true,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(GlLink).classes()).toContain('is-focused');
+ });
+ });
+
+ it('renders visible gl-icon component when `isSet` prop is true', () => {
+ wrapper.setData({
+ isSet: true,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const iconEl = wrapper.find(GlIcon);
+
+ expect(iconEl.isVisible()).toBe(true);
+ expect(iconEl.props('name')).toBe('mobile-issue-close');
+ });
+ });
+
+ it('renders visible span element as placeholder instead of gl-icon when `isSet` prop is false', () => {
+ wrapper.setData({
+ isSet: false,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const placeholderEl = wrapper.find('[data-testid="no-icon"]');
+
+ expect(placeholderEl.isVisible()).toBe(true);
+ });
+ });
+
+ it('renders label color element', () => {
+ const colorEl = wrapper.find('[data-testid="label-color-box"]');
+
+ expect(colorEl.exists()).toBe(true);
+ expect(colorEl.attributes('style')).toBe('background-color: rgb(186, 218, 85);');
+ });
+
+ it('renders label title', () => {
+ expect(wrapper.text()).toContain(mockRegularLabel.title);
+ });
+ });
+});
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 126fd5438c4..ee4e9090e5d 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
@@ -89,6 +89,19 @@ describe('LabelsSelectRoot', () => {
expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative');
});
+ it('renders component root element with CSS class `is-standalone` when `state.variant` is "standalone"', () => {
+ const wrapperStandalone = createComponent({
+ ...mockConfig,
+ variant: 'standalone',
+ });
+
+ return wrapperStandalone.vm.$nextTick(() => {
+ expect(wrapperStandalone.classes()).toContain('is-standalone');
+
+ wrapperStandalone.destroy();
+ });
+ });
+
it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => {
expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
});
@@ -101,13 +114,16 @@ describe('LabelsSelectRoot', () => {
const wrapperDropdownValue = createComponent(mockConfig, {
default: 'None',
});
+ wrapperDropdownValue.vm.$store.state.showDropdownButton = false;
- const valueComp = wrapperDropdownValue.find(DropdownValue);
+ return wrapperDropdownValue.vm.$nextTick(() => {
+ const valueComp = wrapperDropdownValue.find(DropdownValue);
- expect(valueComp.exists()).toBe(true);
- expect(valueComp.text()).toBe('None');
+ expect(valueComp.exists()).toBe(true);
+ expect(valueComp.text()).toBe('None');
- wrapperDropdownValue.destroy();
+ wrapperDropdownValue.destroy();
+ });
});
it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', () => {
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
index a863cddbaee..e1008d13fc2 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
@@ -30,15 +30,16 @@ export const mockConfig = {
allowLabelEdit: true,
allowLabelCreate: true,
allowScopedLabels: true,
+ allowMultiselect: true,
labelsListTitle: 'Assign labels',
labelsCreateTitle: 'Create label',
+ variant: 'sidebar',
dropdownOnly: false,
selectedLabels: [mockRegularLabel, mockScopedLabel],
labelsSelectInProgress: false,
labelsFetchPath: '/gitlab-org/my-project/-/labels.json',
labelsManagePath: '/gitlab-org/my-project/-/labels',
labelsFilterBasePath: '/gitlab-org/my-project/issues',
- scopedLabelsDocumentationPath: '/help/user/project/labels.md#scoped-labels-premium',
};
export const mockSuggestedColors = {
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 6e2363ba96f..072d8fe2fe2 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
@@ -15,7 +15,7 @@ describe('LabelsSelect Actions', () => {
};
beforeEach(() => {
- state = Object.assign({}, defaultState());
+ state = { ...defaultState() };
});
describe('setInitialState', () => {
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js
index bfceaa0828b..b866117efcf 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js
@@ -5,19 +5,25 @@ describe('LabelsSelect Getters', () => {
it('returns string "Label" when state.labels has no selected labels', () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
- expect(getters.dropdownButtonText({ labels })).toBe('Label');
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Label',
+ );
});
it('returns label title when state.labels has only 1 label', () => {
const labels = [{ id: 1, title: 'Foobar', set: true }];
- expect(getters.dropdownButtonText({ labels })).toBe('Foobar');
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Foobar',
+ );
});
it('returns first label title and remaining labels count when state.labels has more than 1 label', () => {
const labels = [{ id: 1, title: 'Foo', set: true }, { id: 2, title: 'Bar', set: true }];
- expect(getters.dropdownButtonText({ labels })).toBe('Foo +1 more');
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Foo +1 more',
+ );
});
});
@@ -28,4 +34,16 @@ describe('LabelsSelect Getters', () => {
expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]);
});
});
+
+ describe('isDropdownVariantSidebar', () => {
+ it('returns `true` when `state.variant` is "sidebar"', () => {
+ expect(getters.isDropdownVariantSidebar({ variant: 'sidebar' })).toBe(true);
+ });
+ });
+
+ describe('isDropdownVariantStandalone', () => {
+ it('returns `true` when `state.variant` is "standalone"', () => {
+ expect(getters.isDropdownVariantStandalone({ variant: 'standalone' })).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
index f6ca98fcc71..8081806e314 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
@@ -29,6 +29,7 @@ describe('LabelsSelect Mutations', () => {
const state = {
dropdownOnly: false,
showDropdownButton: false,
+ variant: 'sidebar',
};
mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
@@ -155,11 +156,11 @@ describe('LabelsSelect Mutations', () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => {
- const updatedLabelIds = [2, 4];
+ const updatedLabelIds = [2];
const state = {
labels,
};
- mutations[types.UPDATE_SELECTED_LABELS](state, { labels });
+ mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] });
state.labels.forEach(label => {
if (updatedLabelIds.includes(label.id)) {
diff --git a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js
new file mode 100644
index 00000000000..bc86ee5a0c6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js
@@ -0,0 +1,104 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import stackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue';
+
+const createComponent = config => {
+ const Component = Vue.extend(stackedProgressBarComponent);
+ const defaultConfig = {
+ successLabel: 'Synced',
+ failureLabel: 'Failed',
+ neutralLabel: 'Out of sync',
+ successCount: 25,
+ failureCount: 10,
+ totalCount: 5000,
+ ...config,
+ };
+
+ return mountComponent(Component, defaultConfig);
+};
+
+describe('StackedProgressBarComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('neutralCount', () => {
+ it('returns neutralCount based on totalCount, successCount and failureCount', () => {
+ expect(vm.neutralCount).toBe(4965); // 5000 - 25 - 10
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('getPercent', () => {
+ it('returns percentage from provided count based on `totalCount`', () => {
+ expect(vm.getPercent(500)).toBe(10);
+ });
+
+ it('returns percentage with decimal place from provided count based on `totalCount`', () => {
+ expect(vm.getPercent(67)).toBe(1.3);
+ });
+
+ it('returns percentage as `< 1` from provided count based on `totalCount` when evaluated value is less than 1', () => {
+ expect(vm.getPercent(10)).toBe('< 1');
+ });
+
+ it('returns 0 if totalCount is falsy', () => {
+ vm = createComponent({ totalCount: 0 });
+
+ expect(vm.getPercent(100)).toBe(0);
+ });
+ });
+
+ describe('barStyle', () => {
+ it('returns style string based on percentage provided', () => {
+ expect(vm.barStyle(50)).toBe('width: 50%;');
+ });
+ });
+
+ describe('getTooltip', () => {
+ describe('when hideTooltips is false', () => {
+ it('returns label string based on label and count provided', () => {
+ expect(vm.getTooltip('Synced', 10)).toBe('Synced: 10');
+ });
+ });
+
+ describe('when hideTooltips is true', () => {
+ beforeEach(() => {
+ vm = createComponent({ hideTooltips: true });
+ });
+
+ it('returns an empty string', () => {
+ expect(vm.getTooltip('Synced', 10)).toBe('');
+ });
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders container element', () => {
+ expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy();
+ });
+
+ it('renders empty state when count is unavailable', () => {
+ const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 });
+
+ expect(vmX.$el.querySelectorAll('.status-unavailable').length).not.toBe(0);
+ vmX.$destroy();
+ });
+
+ it('renders bar elements when count is available', () => {
+ expect(vm.$el.querySelectorAll('.status-green').length).not.toBe(0);
+ expect(vm.$el.querySelectorAll('.status-neutral').length).not.toBe(0);
+ expect(vm.$el.querySelectorAll('.status-red').length).not.toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/tabs/tab_spec.js b/spec/frontend/vue_shared/components/tabs/tab_spec.js
new file mode 100644
index 00000000000..8cf07a9177c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/tabs/tab_spec.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import Tab from '~/vue_shared/components/tabs/tab.vue';
+
+describe('Tab component', () => {
+ const Component = Vue.extend(Tab);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component);
+ });
+
+ it('sets localActive to equal active', done => {
+ vm.active = true;
+
+ vm.$nextTick(() => {
+ expect(vm.localActive).toBe(true);
+
+ done();
+ });
+ });
+
+ it('sets active class', done => {
+ vm.active = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.classList).toContain('active');
+
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/tabs/tabs_spec.js b/spec/frontend/vue_shared/components/tabs/tabs_spec.js
new file mode 100644
index 00000000000..49d92094b34
--- /dev/null
+++ b/spec/frontend/vue_shared/components/tabs/tabs_spec.js
@@ -0,0 +1,61 @@
+import Vue from 'vue';
+import Tabs from '~/vue_shared/components/tabs/tabs';
+import Tab from '~/vue_shared/components/tabs/tab.vue';
+
+describe('Tabs component', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = new Vue({
+ components: {
+ Tabs,
+ Tab,
+ },
+ render(h) {
+ return h('div', [
+ h('tabs', [
+ h('tab', { attrs: { title: 'Testing', active: true } }, 'First tab'),
+ h('tab', [h('template', { slot: 'title' }, 'Test slot'), 'Second tab']),
+ ]),
+ ]);
+ },
+ }).$mount();
+
+ return vm.$nextTick();
+ });
+
+ describe('tab links', () => {
+ it('renders links for tabs', () => {
+ expect(vm.$el.querySelectorAll('a').length).toBe(2);
+ });
+
+ it('renders link titles from props', () => {
+ expect(vm.$el.querySelector('a').textContent).toContain('Testing');
+ });
+
+ it('renders link titles from slot', () => {
+ expect(vm.$el.querySelectorAll('a')[1].textContent).toContain('Test slot');
+ });
+
+ it('renders active class', () => {
+ expect(vm.$el.querySelector('a').classList).toContain('active');
+ });
+
+ it('updates active class on click', () => {
+ vm.$el.querySelectorAll('a')[1].click();
+
+ return vm.$nextTick(() => {
+ expect(vm.$el.querySelector('a').classList).not.toContain('active');
+ expect(vm.$el.querySelectorAll('a')[1].classList).toContain('active');
+ });
+ });
+ });
+
+ describe('content', () => {
+ it('renders content panes', () => {
+ expect(vm.$el.querySelectorAll('.tab-pane').length).toBe(2);
+ expect(vm.$el.querySelectorAll('.tab-pane')[0].textContent).toContain('First tab');
+ expect(vm.$el.querySelectorAll('.tab-pane')[1].textContent).toContain('Second tab');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/toggle_button_spec.js b/spec/frontend/vue_shared/components/toggle_button_spec.js
new file mode 100644
index 00000000000..83bbb37a89a
--- /dev/null
+++ b/spec/frontend/vue_shared/components/toggle_button_spec.js
@@ -0,0 +1,101 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import toggleButton from '~/vue_shared/components/toggle_button.vue';
+
+describe('Toggle Button', () => {
+ let vm;
+ let Component;
+
+ beforeEach(() => {
+ Component = Vue.extend(toggleButton);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('render output', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ value: true,
+ name: 'foo',
+ });
+ });
+
+ it('renders input with provided name', () => {
+ expect(vm.$el.querySelector('input').getAttribute('name')).toEqual('foo');
+ });
+
+ it('renders input with provided value', () => {
+ expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true');
+ });
+
+ it('renders input status icon', () => {
+ expect(vm.$el.querySelectorAll('span.toggle-icon').length).toEqual(1);
+ expect(vm.$el.querySelectorAll('svg.s16.toggle-icon-svg').length).toEqual(1);
+ });
+ });
+
+ describe('is-checked', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ value: true,
+ });
+
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ });
+
+ it('renders is checked class', () => {
+ expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true);
+ });
+
+ it('sets aria-label representing toggle state', () => {
+ vm.value = true;
+
+ expect(vm.ariaLabel).toEqual('Toggle Status: ON');
+
+ vm.value = false;
+
+ expect(vm.ariaLabel).toEqual('Toggle Status: OFF');
+ });
+
+ it('emits change event when clicked', () => {
+ vm.$el.querySelector('button').click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('change', false);
+ });
+ });
+
+ describe('is-disabled', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ value: true,
+ disabledInput: true,
+ });
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ });
+
+ it('renders disabled button', () => {
+ expect(vm.$el.querySelector('button').classList.contains('is-disabled')).toEqual(true);
+ });
+
+ it('does not emit change event when clicked', () => {
+ vm.$el.querySelector('button').click();
+
+ expect(vm.$emit).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('is-loading', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ value: true,
+ isLoading: true,
+ });
+ });
+
+ it('renders loading class', () => {
+ expect(vm.$el.querySelector('button').classList.contains('is-loading')).toEqual(true);
+ });
+ });
+});
diff --git a/spec/frontend/wikis_spec.js b/spec/frontend/wikis_spec.js
index 1d17c8b0777..e5d869840aa 100644
--- a/spec/frontend/wikis_spec.js
+++ b/spec/frontend/wikis_spec.js
@@ -14,6 +14,7 @@ describe('Wikis', () => {
<option value="asciidoc">AsciiDoc</option>
<option value="org">Org</option>
</select>
+ <textarea id="wiki_content"></textarea>
<code class="js-markup-link-example">{Link title}[link:page-slug]</code>
</form>
`;
@@ -24,6 +25,10 @@ describe('Wikis', () => {
let changeFormatSelect;
let linkExample;
+ const findBeforeUnloadWarning = () => window.onbeforeunload?.();
+ const findContent = () => document.getElementById('wiki_content');
+ const findForm = () => document.querySelector('.wiki-form');
+
describe('when the wiki page is being created', () => {
const formHtmlFixture = editFormHtmlFixture({ newPage: true });
@@ -94,6 +99,27 @@ describe('Wikis', () => {
expect(linkExample.innerHTML).toBe(text);
});
+
+ it('starts with no unload warning', () => {
+ expect(findBeforeUnloadWarning()).toBeUndefined();
+ });
+
+ describe('when wiki content is updated', () => {
+ beforeEach(() => {
+ const content = findContent();
+ content.value = 'Lorem ipsum dolar sit!';
+ content.dispatchEvent(new Event('input'));
+ });
+
+ it('sets before unload warning', () => {
+ expect(findBeforeUnloadWarning()).toBe('');
+ });
+
+ it('when form submitted, unsets before unload warning', () => {
+ findForm().dispatchEvent(new Event('submit'));
+ expect(findBeforeUnloadWarning()).toBeUndefined();
+ });
+ });
});
});
});
diff --git a/spec/frontend_integration/.eslintrc.yml b/spec/frontend_integration/.eslintrc.yml
new file mode 100644
index 00000000000..26b6f935ffb
--- /dev/null
+++ b/spec/frontend_integration/.eslintrc.yml
@@ -0,0 +1,6 @@
+---
+extends: ../frontend/.eslintrc.yml
+settings:
+ import/resolver:
+ jest:
+ jestConfigFile: 'jest.config.integration.js'
diff --git a/spec/frontend_integration/README.md b/spec/frontend_integration/README.md
new file mode 100644
index 00000000000..573a385d81e
--- /dev/null
+++ b/spec/frontend_integration/README.md
@@ -0,0 +1,17 @@
+## Frontend Integration Specs
+
+This directory contains Frontend integration specs. Go to `spec/frontend` if you're looking for Frontend unit tests.
+
+Frontend integration specs:
+
+- Mock out the Backend.
+- Don't test individual components, but instead test use cases.
+- Are expected to run slower than unit tests.
+- Could end up having their own environment.
+
+As a result, they deserve their own special place.
+
+## References
+
+- https://docs.gitlab.com/ee/development/testing_guide/testing_levels.html#frontend-integration-tests
+- https://gitlab.com/gitlab-org/gitlab/-/issues/208800
diff --git a/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap b/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap
new file mode 100644
index 00000000000..a76f7960d03
--- /dev/null
+++ b/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap
@@ -0,0 +1,136 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`WebIDE runs 1`] = `
+<div>
+ <article
+ class="ide position-relative d-flex flex-column align-items-stretch"
+ >
+ <div
+ class="ide-view flex-grow d-flex"
+ >
+ <div
+ class="file-finder-overlay"
+ style="display: none;"
+ >
+ (jest: contents hidden)
+ </div>
+ <div
+ class="multi-file-commit-panel flex-column"
+ style="width: 340px;"
+ >
+ <div
+ class="multi-file-commit-panel-inner"
+ >
+ <div
+ class="multi-file-loading-container"
+ >
+ <div
+ class="animation-container"
+ >
+ <div
+ class="skeleton-line-1"
+ />
+ <div
+ class="skeleton-line-2"
+ />
+ <div
+ class="skeleton-line-3"
+ />
+ </div>
+ </div>
+ <div
+ class="multi-file-loading-container"
+ >
+ <div
+ class="animation-container"
+ >
+ <div
+ class="skeleton-line-1"
+ />
+ <div
+ class="skeleton-line-2"
+ />
+ <div
+ class="skeleton-line-3"
+ />
+ </div>
+ </div>
+ <div
+ class="multi-file-loading-container"
+ >
+ <div
+ class="animation-container"
+ >
+ <div
+ class="skeleton-line-1"
+ />
+ <div
+ class="skeleton-line-2"
+ />
+ <div
+ class="skeleton-line-3"
+ />
+ </div>
+ </div>
+ </div>
+ <div
+ class="position-absolute position-top-0 position-bottom-0 drag-handle position-right-0"
+ size="340"
+ style="cursor: ew-resize;"
+ />
+ </div>
+ <div
+ class="multi-file-edit-pane"
+ >
+ <div
+ class="ide-empty-state"
+ >
+ <div
+ class="row js-empty-state"
+ >
+ <div
+ class="col-12"
+ >
+ <div
+ class="svg-content svg-250"
+ >
+ <img
+ src="/test/empty_state.svg"
+ />
+ </div>
+ </div>
+ <div
+ class="col-12"
+ >
+ <div
+ class="text-content text-center"
+ >
+ <h4>
+ Make and review changes in the browser with the Web IDE
+ </h4>
+ <div
+ class="gl-spinner-container"
+ >
+ <span
+ aria-hidden="true"
+ aria-label="Loading"
+ class="align-text-bottom gl-spinner gl-spinner-orange gl-spinner-md"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <footer
+ class="ide-status-bar"
+ >
+ <div
+ class="ide-status-list d-flex ml-auto"
+ >
+ </div>
+ </footer>
+ </article>
+</div>
+`;
diff --git a/spec/frontend_integration/ide/ide_integration_spec.js b/spec/frontend_integration/ide/ide_integration_spec.js
new file mode 100644
index 00000000000..7e8fb3a32ee
--- /dev/null
+++ b/spec/frontend_integration/ide/ide_integration_spec.js
@@ -0,0 +1,100 @@
+/**
+ * WARNING: WIP
+ *
+ * Please do not copy from this spec or use it as an example for anything.
+ *
+ * This is in place to iteratively set up the frontend integration testing environment
+ * and will be improved upon in a later iteration.
+ *
+ * See https://gitlab.com/gitlab-org/gitlab/-/issues/208800 for more information.
+ */
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import { initIde } from '~/ide';
+
+jest.mock('~/api', () => {
+ return {
+ project: jest.fn().mockImplementation(() => new Promise(() => {})),
+ };
+});
+
+jest.mock('~/ide/services/gql', () => {
+ return {
+ query: jest.fn().mockImplementation(() => new Promise(() => {})),
+ };
+});
+
+describe('WebIDE', () => {
+ let vm;
+ let root;
+ let mock;
+ let initData;
+ let location;
+
+ beforeEach(() => {
+ root = document.createElement('div');
+ initData = {
+ emptyStateSvgPath: '/test/empty_state.svg',
+ noChangesStateSvgPath: '/test/no_changes_state.svg',
+ committedStateSvgPath: '/test/committed_state.svg',
+ pipelinesEmptyStateSvgPath: '/test/pipelines_empty_state.svg',
+ promotionSvgPath: '/test/promotion.svg',
+ ciHelpPagePath: '/test/ci_help_page',
+ webIDEHelpPagePath: '/test/web_ide_help_page',
+ clientsidePreviewEnabled: 'true',
+ renderWhitespaceInCode: 'false',
+ codesandboxBundlerUrl: 'test/codesandbox_bundler',
+ };
+
+ mock = new MockAdapter(axios);
+ mock.onAny('*').reply(() => new Promise(() => {}));
+
+ location = { pathname: '/-/ide/project/gitlab-test/test', search: '', hash: '' };
+ Object.defineProperty(window, 'location', {
+ get() {
+ return location;
+ },
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ vm = null;
+
+ mock.restore();
+ });
+
+ const createComponent = () => {
+ const el = document.createElement('div');
+ Object.assign(el.dataset, initData);
+ root.appendChild(el);
+ vm = initIde(el);
+ };
+
+ expect.addSnapshotSerializer({
+ test(value) {
+ return value instanceof HTMLElement && !value.$_hit;
+ },
+ print(element, serialize) {
+ element.$_hit = true;
+ element.querySelectorAll('[style]').forEach(el => {
+ el.$_hit = true;
+ if (el.style.display === 'none') {
+ el.textContent = '(jest: contents hidden)';
+ }
+ });
+
+ return serialize(element)
+ .replace(/^\s*<!---->$/gm, '')
+ .replace(/\n\s*\n/gm, '\n');
+ },
+ });
+
+ it('runs', () => {
+ createComponent();
+
+ return vm.$nextTick().then(() => {
+ expect(root).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/graphql/gitlab_schema_spec.rb b/spec/graphql/gitlab_schema_spec.rb
index 0f21a55f7e9..8960ad91543 100644
--- a/spec/graphql/gitlab_schema_spec.rb
+++ b/spec/graphql/gitlab_schema_spec.rb
@@ -191,13 +191,17 @@ describe GitlabSchema do
context 'for other classes' do
# We cannot use an anonymous class here as `GlobalID` expects `.name` not
# to return `nil`
- class TestGlobalId
- include GlobalID::Identification
- attr_accessor :id
-
- def initialize(id)
- @id = id
+ before do
+ test_global_id = Class.new do
+ include GlobalID::Identification
+ attr_accessor :id
+
+ def initialize(id)
+ @id = id
+ end
end
+
+ stub_const('TestGlobalId', test_global_id)
end
it 'falls back to a regular find' do
diff --git a/spec/graphql/mutations/alert_management/create_alert_issue_spec.rb b/spec/graphql/mutations/alert_management/create_alert_issue_spec.rb
new file mode 100644
index 00000000000..1e51767cf0e
--- /dev/null
+++ b/spec/graphql/mutations/alert_management/create_alert_issue_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Mutations::AlertManagement::CreateAlertIssue do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project, status: 'triggered') }
+ let(:args) { { project_path: project.full_path, iid: alert.iid } }
+
+ specify { expect(described_class).to require_graphql_authorizations(:update_alert_management_alert) }
+
+ describe '#resolve' do
+ subject(:resolve) { mutation_for(project, current_user).resolve(args) }
+
+ context 'user has access to project' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ context 'when CreateAlertIssueService responds with success' do
+ it 'returns the issue with no errors' do
+ expect(resolve).to eq(
+ alert: alert.reload,
+ issue: Issue.last!,
+ errors: []
+ )
+ end
+ end
+
+ context 'when CreateAlertIssue responds with an error' do
+ before do
+ allow_any_instance_of(::AlertManagement::CreateAlertIssueService)
+ .to receive(:execute)
+ .and_return(ServiceResponse.error(payload: { issue: nil }, message: 'An issue already exists'))
+ end
+
+ it 'returns errors' do
+ expect(resolve).to eq(
+ alert: alert,
+ issue: nil,
+ errors: ['An issue already exists']
+ )
+ end
+ end
+ end
+
+ context 'when resource is not accessible to the user' do
+ it 'raises an error if the resource is not accessible to the user' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+
+ private
+
+ def mutation_for(project, user)
+ described_class.new(object: project, context: { current_user: user }, field: nil)
+ end
+end
diff --git a/spec/graphql/mutations/alert_management/update_alert_status_spec.rb b/spec/graphql/mutations/alert_management/update_alert_status_spec.rb
new file mode 100644
index 00000000000..8b9abd9497d
--- /dev/null
+++ b/spec/graphql/mutations/alert_management/update_alert_status_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Mutations::AlertManagement::UpdateAlertStatus do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:alert) { create(:alert_management_alert, :triggered) }
+ let_it_be(:project) { alert.project }
+ let(:new_status) { Types::AlertManagement::StatusEnum.values['ACKNOWLEDGED'].value }
+ let(:args) { { status: new_status, project_path: project.full_path, iid: alert.iid } }
+
+ specify { expect(described_class).to require_graphql_authorizations(:update_alert_management_alert) }
+
+ describe '#resolve' do
+ subject(:resolve) { mutation_for(project, current_user).resolve(args) }
+
+ context 'user has access to project' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ it 'changes the status' do
+ expect { resolve }.to change { alert.reload.acknowledged? }.to(true)
+ end
+
+ it 'returns the alert with no errors' do
+ expect(resolve).to eq(
+ alert: alert,
+ errors: []
+ )
+ end
+
+ context 'error occurs when updating' do
+ it 'returns the alert with errors' do
+ # Stub an error on the alert
+ allow_next_instance_of(Resolvers::AlertManagementAlertResolver) do |resolver|
+ allow(resolver).to receive(:resolve).and_return(alert)
+ end
+
+ allow(alert).to receive(:save).and_return(false)
+ allow(alert).to receive(:errors).and_return(
+ double(full_messages: %w(foo bar))
+ )
+ expect(resolve).to eq(
+ alert: alert,
+ errors: ['foo and bar']
+ )
+ end
+
+ context 'invalid status given' do
+ let(:new_status) { 'invalid_status' }
+
+ it 'returns the alert with errors' do
+ expect(resolve).to eq(
+ alert: alert,
+ errors: [_('Invalid status')]
+ )
+ end
+ end
+ end
+ end
+
+ it 'raises an error if the resource is not accessible to the user' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ private
+
+ def mutation_for(project, user)
+ described_class.new(object: project, context: { current_user: user }, field: nil)
+ end
+end
diff --git a/spec/graphql/mutations/branches/create_spec.rb b/spec/graphql/mutations/branches/create_spec.rb
new file mode 100644
index 00000000000..744f8f1f2bc
--- /dev/null
+++ b/spec/graphql/mutations/branches/create_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Mutations::Branches::Create do
+ subject(:mutation) { described_class.new(object: nil, context: context, field: nil) }
+
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:context) do
+ GraphQL::Query::Context.new(
+ query: OpenStruct.new(schema: nil),
+ values: { current_user: user },
+ object: nil
+ )
+ end
+
+ describe '#resolve' do
+ subject { mutation.resolve(project_path: project.full_path, name: branch, ref: ref) }
+
+ let(:branch) { 'new_branch' }
+ let(:ref) { 'master' }
+ let(:mutated_branch) { subject[:branch] }
+
+ it 'raises an error if the resource is not accessible to the user' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+
+ context 'when the user can create a branch' do
+ before do
+ project.add_developer(user)
+
+ allow_next_instance_of(::Branches::CreateService, project, user) do |create_service|
+ allow(create_service).to receive(:execute).with(branch, ref) { service_result }
+ end
+ end
+
+ context 'when service successfully creates a new branch' do
+ let(:service_result) { { status: :success, branch: double(name: branch) } }
+
+ it 'returns a new branch' do
+ expect(mutated_branch.name).to eq(branch)
+ expect(subject[:errors]).to be_empty
+ end
+ end
+
+ context 'when service fails to create a new branch' do
+ let(:service_result) { { status: :error, message: 'Branch already exists' } }
+
+ it { expect(mutated_branch).to be_nil }
+ it { expect(subject[:errors]).to eq(['Branch already exists']) }
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/design_management/delete_spec.rb b/spec/graphql/mutations/design_management/delete_spec.rb
new file mode 100644
index 00000000000..60be6dad62a
--- /dev/null
+++ b/spec/graphql/mutations/design_management/delete_spec.rb
@@ -0,0 +1,145 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Mutations::DesignManagement::Delete do
+ include DesignManagementTestHelpers
+
+ let(:issue) { create(:issue) }
+ let(:current_designs) { issue.designs.current }
+ let(:user) { issue.author }
+ let(:project) { issue.project }
+ let(:design_a) { create(:design, :with_file, issue: issue) }
+ let(:design_b) { create(:design, :with_file, issue: issue) }
+ let(:design_c) { create(:design, :with_file, issue: issue) }
+ let(:filenames) { [design_a, design_b, design_c].map(&:filename) }
+
+ let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+
+ before do
+ stub_const('Errors', Gitlab::Graphql::Errors, transfer_nested_constants: true)
+ end
+
+ def run_mutation
+ mutation = described_class.new(object: nil, context: { current_user: user }, field: nil)
+ mutation.resolve(project_path: project.full_path, iid: issue.iid, filenames: filenames)
+ end
+
+ describe '#resolve' do
+ let(:expected_response) do
+ { errors: [], version: DesignManagement::Version.for_issue(issue).ordered.first }
+ end
+
+ shared_examples "failures" do |error: Gitlab::Graphql::Errors::ResourceNotAvailable|
+ it "raises #{error.name}" do
+ expect { run_mutation }.to raise_error(error)
+ end
+ end
+
+ shared_examples "resource not available" do
+ it_behaves_like "failures"
+ end
+
+ context "when the feature is not available" do
+ before do
+ enable_design_management(false)
+ end
+
+ it_behaves_like "resource not available"
+ end
+
+ context "when the feature is available" do
+ before do
+ enable_design_management(true)
+ end
+
+ context "when the user is not allowed to delete designs" do
+ let(:user) { create(:user) }
+
+ it_behaves_like "resource not available"
+ end
+
+ context 'deleting an already deleted file' do
+ before do
+ run_mutation
+ end
+
+ it 'fails with an argument error' do
+ expect { run_mutation }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
+ end
+ end
+
+ context "when deleting all the designs" do
+ let(:response) { run_mutation }
+
+ it "returns a new version, and no errors" do
+ expect(response).to include(expected_response)
+ end
+
+ describe 'the current designs' do
+ before do
+ run_mutation
+ end
+
+ it 'is empty' do
+ expect(current_designs).to be_empty
+ end
+ end
+
+ it 'runs no more than 28 queries' do
+ filenames.each(&:present?) # ignore setup
+ # Queries: as of 2019-08-28
+ # -------------
+ # 01. routing query
+ # 02. find project by id
+ # 03. project.project_features
+ # 04. find namespace by id and type
+ # 05,06. project.authorizations for user (same query twice)
+ # 07. find issue by iid
+ # 08. find project by id
+ # 09. find namespace by id
+ # 10. find group namespace by id
+ # 11. project.authorizations for user (same query as 5)
+ # 12. project.project_features (same query as 3)
+ # 13. project.authorizations for user (same query as 5)
+ # 14. current designs by filename and issue
+ # 15, 16 project.authorizations for user (same query as 5)
+ # 17. find route by id and source_type
+ # ------------- our queries are below:
+ # 18. start transaction 1
+ # 19. start transaction 2
+ # 20. find version by sha and issue
+ # 21. exists version with sha and issue?
+ # 22. leave transaction 2
+ # 23. create version with sha and issue
+ # 24. create design-version links
+ # 25. validate version.actions.present?
+ # 26. validate version.issue.present?
+ # 27. validate version.sha is unique
+ # 28. leave transaction 1
+ #
+ expect { run_mutation }.not_to exceed_query_limit(28)
+ end
+ end
+
+ context "when deleting a design" do
+ let(:filenames) { [design_a.filename] }
+ let(:response) { run_mutation }
+
+ it "returns the expected response" do
+ expect(response).to include(expected_response)
+ end
+
+ describe 'the current designs' do
+ before do
+ run_mutation
+ end
+
+ it 'does contain designs b and c' do
+ expect(current_designs).to contain_exactly(design_b, design_c)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/design_management/upload_spec.rb b/spec/graphql/mutations/design_management/upload_spec.rb
new file mode 100644
index 00000000000..783af70448c
--- /dev/null
+++ b/spec/graphql/mutations/design_management/upload_spec.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe Mutations::DesignManagement::Upload do
+ include DesignManagementTestHelpers
+ include ConcurrentHelpers
+
+ let(:issue) { create(:issue) }
+ let(:user) { issue.author }
+ let(:project) { issue.project }
+
+ subject(:mutation) do
+ described_class.new(object: nil, context: { current_user: user }, field: nil)
+ end
+
+ def run_mutation(files_to_upload = files, project_path = project.full_path, iid = issue.iid)
+ mutation = described_class.new(object: nil, context: { current_user: user }, field: nil)
+ mutation.resolve(project_path: project_path, iid: iid, files: files_to_upload)
+ end
+
+ describe "#resolve" do
+ let(:files) { [fixture_file_upload('spec/fixtures/dk.png')] }
+
+ subject(:resolve) do
+ mutation.resolve(project_path: project.full_path, iid: issue.iid, files: files)
+ end
+
+ shared_examples "resource not available" do
+ it "raises an error" do
+ expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context "when the feature is not available" do
+ it_behaves_like "resource not available"
+ end
+
+ context "when the feature is available" do
+ before do
+ enable_design_management
+ end
+
+ describe 'contention in the design repo' do
+ before do
+ issue.design_collection.repository.create_if_not_exists
+ end
+
+ let(:files) do
+ ['dk.png', 'rails_sample.jpg', 'banana_sample.gif']
+ .cycle
+ .take(Concurrent.processor_count * 2)
+ .map { |f| RenameableUpload.unique_file(f) }
+ end
+
+ def creates_designs
+ prior_count = DesignManagement::Design.count
+
+ expect { yield }.not_to raise_error
+
+ expect(DesignManagement::Design.count).to eq(prior_count + files.size)
+ end
+
+ describe 'running requests in parallel' do
+ it 'does not cause errors' do
+ creates_designs do
+ run_parallel(files.map { |f| -> { run_mutation([f]) } })
+ end
+ end
+ end
+
+ describe 'running requests in parallel on different issues' do
+ it 'does not cause errors' do
+ creates_designs do
+ issues = create_list(:issue, files.size, author: user)
+ issues.each { |i| i.project.add_developer(user) }
+ blocks = files.zip(issues).map do |(f, i)|
+ -> { run_mutation([f], i.project.full_path, i.iid) }
+ end
+
+ run_parallel(blocks)
+ end
+ end
+ end
+
+ describe 'running requests in serial' do
+ it 'does not cause errors' do
+ creates_designs do
+ files.each do |f|
+ run_mutation([f])
+ end
+ end
+ end
+ end
+ end
+
+ context "when the user is not allowed to upload designs" do
+ let(:user) { create(:user) }
+
+ it_behaves_like "resource not available"
+ end
+
+ context "a valid design" do
+ it "returns the updated designs" do
+ expect(resolve[:errors]).to eq []
+ expect(resolve[:designs].map(&:filename)).to contain_exactly("dk.png")
+ end
+ end
+
+ context "context when passing an invalid project" do
+ let(:project) { build(:project) }
+
+ it_behaves_like "resource not available"
+ end
+
+ context "context when passing an invalid issue" do
+ let(:issue) { build(:issue) }
+
+ it_behaves_like "resource not available"
+ end
+
+ context "when creating designs causes errors" do
+ before do
+ fake_service = double(::DesignManagement::SaveDesignsService)
+
+ allow(fake_service).to receive(:execute).and_return(status: :error, message: "Something failed")
+ allow(::DesignManagement::SaveDesignsService).to receive(:new).and_return(fake_service)
+ end
+
+ it "wraps the errors" do
+ expect(resolve[:errors]).to eq(["Something failed"])
+ expect(resolve[:designs]).to eq([])
+ end
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/issues/set_confidential_spec.rb b/spec/graphql/mutations/issues/set_confidential_spec.rb
index 6031953c869..c90ce2658d6 100644
--- a/spec/graphql/mutations/issues/set_confidential_spec.rb
+++ b/spec/graphql/mutations/issues/set_confidential_spec.rb
@@ -8,6 +8,8 @@ describe Mutations::Issues::SetConfidential do
subject(: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(:confidential) { true }
let(:mutated_issue) { subject[:issue] }
diff --git a/spec/graphql/mutations/issues/set_due_date_spec.rb b/spec/graphql/mutations/issues/set_due_date_spec.rb
index 73ba11fc551..84df6fce7c7 100644
--- a/spec/graphql/mutations/issues/set_due_date_spec.rb
+++ b/spec/graphql/mutations/issues/set_due_date_spec.rb
@@ -8,6 +8,8 @@ describe Mutations::Issues::SetDueDate do
subject(: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(:due_date) { 2.days.since }
let(:mutated_issue) { subject[:issue] }
diff --git a/spec/graphql/mutations/issues/update_spec.rb b/spec/graphql/mutations/issues/update_spec.rb
index da286bb4092..8c3d01918fd 100644
--- a/spec/graphql/mutations/issues/update_spec.rb
+++ b/spec/graphql/mutations/issues/update_spec.rb
@@ -16,6 +16,8 @@ describe Mutations::Issues::Update do
let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
let(:mutated_issue) { subject[:issue] }
+ specify { expect(described_class).to require_graphql_authorizations(:update_issue) }
+
describe '#resolve' do
let(:mutation_params) do
{
diff --git a/spec/graphql/mutations/merge_requests/set_labels_spec.rb b/spec/graphql/mutations/merge_requests/set_labels_spec.rb
index f58f35eb6f3..0fd2c20a5c8 100644
--- a/spec/graphql/mutations/merge_requests/set_labels_spec.rb
+++ b/spec/graphql/mutations/merge_requests/set_labels_spec.rb
@@ -8,6 +8,8 @@ describe Mutations::MergeRequests::SetLabels do
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+ specify { expect(described_class).to require_graphql_authorizations(:update_merge_request) }
+
describe '#resolve' do
let(:label) { create(:label, project: merge_request.project) }
let(:label2) { create(:label, project: merge_request.project) }
diff --git a/spec/graphql/mutations/merge_requests/set_locked_spec.rb b/spec/graphql/mutations/merge_requests/set_locked_spec.rb
index 12ae1314f22..d5219c781fd 100644
--- a/spec/graphql/mutations/merge_requests/set_locked_spec.rb
+++ b/spec/graphql/mutations/merge_requests/set_locked_spec.rb
@@ -8,6 +8,8 @@ describe Mutations::MergeRequests::SetLocked do
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+ specify { expect(described_class).to require_graphql_authorizations(:update_merge_request) }
+
describe '#resolve' do
let(:locked) { true }
let(:mutated_merge_request) { subject[:merge_request] }
diff --git a/spec/graphql/mutations/merge_requests/set_milestone_spec.rb b/spec/graphql/mutations/merge_requests/set_milestone_spec.rb
index ad7f2df0842..d77ec4de4d0 100644
--- a/spec/graphql/mutations/merge_requests/set_milestone_spec.rb
+++ b/spec/graphql/mutations/merge_requests/set_milestone_spec.rb
@@ -8,6 +8,8 @@ describe Mutations::MergeRequests::SetMilestone do
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+ specify { expect(described_class).to require_graphql_authorizations(:update_merge_request) }
+
describe '#resolve' do
let(:milestone) { create(:milestone, project: merge_request.project) }
let(:mutated_merge_request) { subject[:merge_request] }
diff --git a/spec/graphql/mutations/merge_requests/set_subscription_spec.rb b/spec/graphql/mutations/merge_requests/set_subscription_spec.rb
index a28bab363f3..cf569a74aa9 100644
--- a/spec/graphql/mutations/merge_requests/set_subscription_spec.rb
+++ b/spec/graphql/mutations/merge_requests/set_subscription_spec.rb
@@ -9,6 +9,8 @@ describe Mutations::MergeRequests::SetSubscription do
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+ specify { expect(described_class).to require_graphql_authorizations(:update_merge_request) }
+
describe '#resolve' do
let(:subscribe) { true }
let(:mutated_merge_request) { subject[:merge_request] }
diff --git a/spec/graphql/mutations/merge_requests/set_wip_spec.rb b/spec/graphql/mutations/merge_requests/set_wip_spec.rb
index 9f0adcf117a..7255d0fe7d7 100644
--- a/spec/graphql/mutations/merge_requests/set_wip_spec.rb
+++ b/spec/graphql/mutations/merge_requests/set_wip_spec.rb
@@ -8,6 +8,8 @@ describe Mutations::MergeRequests::SetWip do
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+ specify { expect(described_class).to require_graphql_authorizations(:update_merge_request) }
+
describe '#resolve' do
let(:wip) { true }
let(:mutated_merge_request) { subject[:merge_request] }
diff --git a/spec/graphql/mutations/todos/mark_all_done_spec.rb b/spec/graphql/mutations/todos/mark_all_done_spec.rb
index 98b22a3e761..4af00307969 100644
--- a/spec/graphql/mutations/todos/mark_all_done_spec.rb
+++ b/spec/graphql/mutations/todos/mark_all_done_spec.rb
@@ -17,6 +17,8 @@ describe Mutations::Todos::MarkAllDone do
let_it_be(:user3) { create(:user) }
+ specify { expect(described_class).to require_graphql_authorizations(:update_user) }
+
describe '#resolve' do
it 'marks all pending todos as done' do
updated_todo_ids = mutation_for(current_user).resolve.dig(:updated_ids)
diff --git a/spec/graphql/mutations/todos/mark_done_spec.rb b/spec/graphql/mutations/todos/mark_done_spec.rb
index 059ef3c8eee..44065f83f74 100644
--- a/spec/graphql/mutations/todos/mark_done_spec.rb
+++ b/spec/graphql/mutations/todos/mark_done_spec.rb
@@ -16,6 +16,8 @@ describe Mutations::Todos::MarkDone do
let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
+ specify { expect(described_class).to require_graphql_authorizations(:update_todo) }
+
describe '#resolve' do
it 'marks a single todo as done' do
result = mark_done_mutation(todo1)
diff --git a/spec/graphql/mutations/todos/restore_spec.rb b/spec/graphql/mutations/todos/restore_spec.rb
index 1637acc2fb5..949ab6a164b 100644
--- a/spec/graphql/mutations/todos/restore_spec.rb
+++ b/spec/graphql/mutations/todos/restore_spec.rb
@@ -14,6 +14,8 @@ describe Mutations::Todos::Restore do
let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
+ specify { expect(described_class).to require_graphql_authorizations(:update_todo) }
+
describe '#resolve' do
it 'restores a single todo' do
result = restore_mutation(todo1)
diff --git a/spec/graphql/resolvers/alert_management/alert_status_counts_resolver_spec.rb b/spec/graphql/resolvers/alert_management/alert_status_counts_resolver_spec.rb
new file mode 100644
index 00000000000..8eb28c8c945
--- /dev/null
+++ b/spec/graphql/resolvers/alert_management/alert_status_counts_resolver_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::AlertManagement::AlertStatusCountsResolver do
+ include GraphqlHelpers
+
+ describe '#resolve' do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let(:args) { {} }
+
+ subject { resolve_alert_status_counts(args) }
+
+ it { is_expected.to be_a(Gitlab::AlertManagement::AlertStatusCounts) }
+ specify { expect(subject.project).to eq(project) }
+
+ private
+
+ def resolve_alert_status_counts(args = {}, context = { current_user: current_user })
+ resolve(described_class, obj: project, args: args, ctx: context)
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/alert_management_alert_resolver_spec.rb b/spec/graphql/resolvers/alert_management_alert_resolver_spec.rb
new file mode 100644
index 00000000000..971a81a826d
--- /dev/null
+++ b/spec/graphql/resolvers/alert_management_alert_resolver_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::AlertManagementAlertResolver do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:alert_1) { create(:alert_management_alert, :resolved, project: project, ended_at: 1.year.ago, events: 2, severity: :high) }
+ let_it_be(:alert_2) { create(:alert_management_alert, :ignored, project: project, events: 1, severity: :critical) }
+ let_it_be(:alert_other_proj) { create(:alert_management_alert) }
+
+ let(:args) { {} }
+
+ subject { resolve_alerts(args) }
+
+ context 'user does not have permission' do
+ it { is_expected.to eq(AlertManagement::Alert.none) }
+ end
+
+ context 'user has permission' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ it { is_expected.to contain_exactly(alert_1, alert_2) }
+
+ context 'finding by iid' do
+ let(:args) { { iid: alert_1.iid } }
+
+ it { is_expected.to contain_exactly(alert_1) }
+ end
+
+ context 'finding by status' do
+ let(:args) { { status: [Types::AlertManagement::StatusEnum.values['IGNORED'].value] } }
+
+ it { is_expected.to contain_exactly(alert_2) }
+ end
+
+ describe 'sorting' do
+ # Other sorting examples in spec/finders/alert_management/alerts_finder_spec.rb
+ context 'when sorting by events count' do
+ let_it_be(:alert_count_6) { create(:alert_management_alert, project: project, events: 6) }
+ let_it_be(:alert_count_3) { create(:alert_management_alert, project: project, events: 3) }
+
+ it 'sorts alerts ascending' do
+ expect(resolve_alerts(sort: :events_count_asc)).to eq [alert_2, alert_1, alert_count_3, alert_count_6]
+ end
+
+ it 'sorts alerts descending' do
+ expect(resolve_alerts(sort: :events_count_desc)).to eq [alert_count_6, alert_count_3, alert_1, alert_2]
+ end
+ end
+ end
+ end
+
+ private
+
+ def resolve_alerts(args = {}, context = { current_user: current_user })
+ resolve(described_class, obj: project, args: args, ctx: context)
+ end
+end
diff --git a/spec/graphql/resolvers/board_lists_resolver_spec.rb b/spec/graphql/resolvers/board_lists_resolver_spec.rb
new file mode 100644
index 00000000000..5f6c440a8ed
--- /dev/null
+++ b/spec/graphql/resolvers/board_lists_resolver_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::BoardListsResolver do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:unauth_user) { create(:user) }
+ let_it_be(:project) { create(:project, creator_id: user.id, namespace: user.namespace ) }
+ let_it_be(:group) { create(:group, :private) }
+ let_it_be(:project_label) { create(:label, project: project, name: 'Development') }
+ let_it_be(:group_label) { create(:group_label, group: group, name: 'Development') }
+
+ shared_examples_for 'group and project board lists resolver' do
+ let(:board) { create(:board, resource_parent: board_parent) }
+
+ before do
+ board_parent.add_developer(user)
+ end
+
+ it 'does not create the backlog list' do
+ lists = resolve_board_lists.items
+
+ expect(lists.count).to eq 1
+ expect(lists[0].list_type).to eq 'closed'
+ end
+
+ context 'with unauthorized user' do
+ it 'raises an error' do
+ expect do
+ resolve_board_lists(current_user: unauth_user)
+ end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when authorized' do
+ let!(:label_list) { create(:list, board: board, label: label) }
+ let!(:backlog_list) { create(:backlog_list, board: board) }
+
+ it 'returns a list of board lists' do
+ lists = resolve_board_lists.items
+
+ expect(lists.count).to eq 3
+ expect(lists.map(&:list_type)).to eq %w(backlog label closed)
+ end
+
+ context 'when another user has list preferences' do
+ before do
+ board.lists.first.update_preferences_for(guest, collapsed: true)
+ end
+
+ it 'returns the complete list of board lists for this user' do
+ lists = resolve_board_lists.items
+
+ expect(lists.count).to eq 3
+ end
+ end
+ end
+ end
+
+ describe '#resolve' do
+ context 'when project boards' do
+ let(:board_parent) { project }
+ let(:label) { project_label }
+
+ it_behaves_like 'group and project board lists resolver'
+ end
+
+ context 'when group boards' do
+ let(:board_parent) { group }
+ let(:label) { group_label }
+
+ it_behaves_like 'group and project board lists resolver'
+ end
+ end
+
+ def resolve_board_lists(args: {}, current_user: user)
+ resolve(described_class, obj: board, args: args, ctx: { current_user: current_user })
+ end
+end
diff --git a/spec/graphql/resolvers/branch_commit_resolver_spec.rb b/spec/graphql/resolvers/branch_commit_resolver_spec.rb
new file mode 100644
index 00000000000..22e1de8f375
--- /dev/null
+++ b/spec/graphql/resolvers/branch_commit_resolver_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::BranchCommitResolver do
+ include GraphqlHelpers
+
+ subject(:commit) { resolve(described_class, obj: branch) }
+
+ let_it_be(:repository) { create(:project, :repository).repository }
+ let(:branch) { repository.find_branch('master') }
+
+ describe '#resolve' do
+ it 'resolves commit' do
+ is_expected.to eq(repository.commits('master', limit: 1).last)
+ end
+
+ context 'when branch does not exist' do
+ let(:branch) { nil }
+
+ it 'returns nil' do
+ is_expected.to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/design_management/design_at_version_resolver_spec.rb b/spec/graphql/resolvers/design_management/design_at_version_resolver_spec.rb
new file mode 100644
index 00000000000..a5054ae3ebf
--- /dev/null
+++ b/spec/graphql/resolvers/design_management/design_at_version_resolver_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::DesignManagement::DesignAtVersionResolver do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:project) { issue.project }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:design_a) { create(:design, issue: issue) }
+ let_it_be(:version_a) { create(:design_version, issue: issue, created_designs: [design_a]) }
+
+ let(:current_user) { user }
+ let(:object) { issue.design_collection }
+ let(:global_id) { GitlabSchema.id_from_object(design_at_version).to_s }
+
+ let(:design_at_version) { ::DesignManagement::DesignAtVersion.new(design: design_a, version: version_a) }
+
+ let(:resource_not_available) { ::Gitlab::Graphql::Errors::ResourceNotAvailable }
+
+ before do
+ enable_design_management
+ project.add_developer(user)
+ end
+
+ describe '#resolve' do
+ context 'when the user cannot see designs' do
+ let(:current_user) { create(:user) }
+
+ it 'raises ResourceNotAvailable' do
+ expect { resolve_design }.to raise_error(resource_not_available)
+ end
+ end
+
+ it 'returns the specified design' do
+ expect(resolve_design).to eq(design_at_version)
+ end
+
+ context 'the ID belongs to a design on another issue' do
+ let(:other_dav) do
+ create(:design_at_version, issue: create(:issue, project: project))
+ end
+
+ let(:global_id) { global_id_of(other_dav) }
+
+ it 'raises ResourceNotAvailable' do
+ expect { resolve_design }.to raise_error(resource_not_available)
+ end
+
+ context 'the current object does not constrain the issue' do
+ let(:object) { nil }
+
+ it 'returns the object' do
+ expect(resolve_design).to eq(other_dav)
+ end
+ end
+ end
+ end
+
+ private
+
+ def resolve_design
+ args = { id: global_id }
+ ctx = { current_user: current_user }
+ eager_resolve(described_class, obj: object, args: args, ctx: ctx)
+ end
+end
diff --git a/spec/graphql/resolvers/design_management/design_resolver_spec.rb b/spec/graphql/resolvers/design_management/design_resolver_spec.rb
new file mode 100644
index 00000000000..857acc3d371
--- /dev/null
+++ b/spec/graphql/resolvers/design_management/design_resolver_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::DesignManagement::DesignResolver do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+
+ before do
+ enable_design_management
+ end
+
+ describe '#resolve' do
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:project) { issue.project }
+ let_it_be(:first_version) { create(:design_version) }
+ let_it_be(:first_design) { create(:design, issue: issue, versions: [first_version]) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:design_on_other_issue) do
+ create(:design, issue: create(:issue, project: project), versions: [create(:design_version)])
+ end
+
+ let(:args) { { id: GitlabSchema.id_from_object(first_design).to_s } }
+ let(:gql_context) { { current_user: current_user } }
+
+ before do
+ project.add_developer(current_user)
+ end
+
+ context 'when the user cannot see designs' do
+ let(:gql_context) { { current_user: create(:user) } }
+
+ it 'returns nothing' do
+ expect(resolve_design).to be_nil
+ end
+ end
+
+ context 'when no argument has been passed' do
+ let(:args) { {} }
+
+ it 'raises an error' do
+ expect { resolve_design }.to raise_error(::Gitlab::Graphql::Errors::ArgumentError, /must/)
+ end
+ end
+
+ context 'when both arguments have been passed' do
+ let(:args) { { filename: first_design.filename, id: GitlabSchema.id_from_object(first_design).to_s } }
+
+ it 'raises an error' do
+ expect { resolve_design }.to raise_error(::Gitlab::Graphql::Errors::ArgumentError, /may/)
+ end
+ end
+
+ context 'by ID' do
+ it 'returns the specified design' do
+ expect(resolve_design).to eq(first_design)
+ end
+
+ context 'the ID belongs to a design on another issue' do
+ let(:args) { { id: GitlabSchema.id_from_object(design_on_other_issue).to_s } }
+
+ it 'returns nothing' do
+ expect(resolve_design).to be_nil
+ end
+ end
+ end
+
+ context 'by filename' do
+ let(:args) { { filename: first_design.filename } }
+
+ it 'returns the specified design' do
+ expect(resolve_design).to eq(first_design)
+ end
+
+ context 'the filename belongs to a design on another issue' do
+ let(:args) { { filename: design_on_other_issue.filename } }
+
+ it 'returns nothing' do
+ expect(resolve_design).to be_nil
+ end
+ end
+ end
+ end
+
+ def resolve_design
+ resolve(described_class, obj: issue.design_collection, args: args, ctx: gql_context)
+ end
+end
diff --git a/spec/graphql/resolvers/design_management/designs_resolver_spec.rb b/spec/graphql/resolvers/design_management/designs_resolver_spec.rb
new file mode 100644
index 00000000000..28fc9e2151d
--- /dev/null
+++ b/spec/graphql/resolvers/design_management/designs_resolver_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::DesignManagement::DesignsResolver do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+
+ before do
+ enable_design_management
+ end
+
+ describe '#resolve' do
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:project) { issue.project }
+ let_it_be(:first_version) { create(:design_version) }
+ let_it_be(:first_design) { create(:design, issue: issue, versions: [first_version]) }
+ let_it_be(:current_user) { create(:user) }
+ let(:gql_context) { { current_user: current_user } }
+ let(:args) { {} }
+
+ before do
+ project.add_developer(current_user)
+ end
+
+ context 'when the user cannot see designs' do
+ let(:gql_context) { { current_user: create(:user) } }
+
+ it 'returns nothing' do
+ expect(resolve_designs).to be_empty
+ end
+ end
+
+ context 'for a design collection' do
+ context 'which contains just a single design' do
+ it 'returns just that design' do
+ expect(resolve_designs).to contain_exactly(first_design)
+ end
+ end
+
+ context 'which contains another design' do
+ it 'returns all designs' do
+ second_version = create(:design_version)
+ second_design = create(:design, issue: issue, versions: [second_version])
+
+ expect(resolve_designs).to contain_exactly(first_design, second_design)
+ end
+ end
+ end
+
+ describe 'filtering' do
+ describe 'by filename' do
+ let(:second_version) { create(:design_version) }
+ let(:second_design) { create(:design, issue: issue, versions: [second_version]) }
+ let(:args) { { filenames: [second_design.filename] } }
+
+ it 'resolves to just the relevant design, ignoring designs with the same filename on different issues' do
+ create(:design, issue: create(:issue, project: project), filename: second_design.filename)
+
+ expect(resolve_designs).to contain_exactly(second_design)
+ end
+ end
+
+ describe 'by id' do
+ let(:second_version) { create(:design_version) }
+ let(:second_design) { create(:design, issue: issue, versions: [second_version]) }
+
+ context 'the ID is on the current issue' do
+ let(:args) { { ids: [GitlabSchema.id_from_object(second_design).to_s] } }
+
+ it 'resolves to just the relevant design' do
+ expect(resolve_designs).to contain_exactly(second_design)
+ end
+ end
+
+ context 'the ID is on a different issue' do
+ let(:third_version) { create(:design_version) }
+ let(:third_design) { create(:design, issue: create(:issue, project: project), versions: [third_version]) }
+
+ let(:args) { { ids: [GitlabSchema.id_from_object(third_design).to_s] } }
+
+ it 'ignores it' do
+ expect(resolve_designs).to be_empty
+ end
+ end
+ end
+ end
+ end
+
+ def resolve_designs
+ resolve(described_class, obj: issue.design_collection, args: args, ctx: gql_context)
+ end
+end
diff --git a/spec/graphql/resolvers/design_management/version/design_at_version_resolver_spec.rb b/spec/graphql/resolvers/design_management/version/design_at_version_resolver_spec.rb
new file mode 100644
index 00000000000..cc9c0436885
--- /dev/null
+++ b/spec/graphql/resolvers/design_management/version/design_at_version_resolver_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::DesignManagement::Version::DesignAtVersionResolver do
+ include GraphqlHelpers
+
+ include_context 'four designs in three versions'
+
+ let(:current_user) { authorized_user }
+ let(:gql_context) { { current_user: current_user } }
+
+ let(:version) { third_version }
+ let(:design) { design_a }
+
+ let(:all_singular_args) do
+ {
+ design_at_version_id: global_id_of(dav(design)),
+ design_id: global_id_of(design),
+ filename: design.filename
+ }
+ end
+
+ shared_examples 'a bad argument' do
+ let(:err_class) { ::Gitlab::Graphql::Errors::ArgumentError }
+
+ it 'raises an appropriate error' do
+ expect { resolve_objects }.to raise_error(err_class)
+ end
+ end
+
+ describe '#resolve' do
+ describe 'passing combinations of arguments' do
+ context 'passing no arguments' do
+ let(:args) { {} }
+
+ it_behaves_like 'a bad argument'
+ end
+
+ context 'passing all arguments' do
+ let(:args) { all_singular_args }
+
+ it_behaves_like 'a bad argument'
+ end
+
+ context 'passing any two arguments' do
+ let(:args) { all_singular_args.slice(*all_singular_args.keys.sample(2)) }
+
+ it_behaves_like 'a bad argument'
+ end
+ end
+
+ %i[design_at_version_id design_id filename].each do |arg|
+ describe "passing #{arg}" do
+ let(:args) { all_singular_args.slice(arg) }
+
+ it 'finds the design' do
+ expect(resolve_objects).to eq(dav(design))
+ end
+
+ context 'when the user cannot see designs' do
+ let(:current_user) { create(:user) }
+
+ it 'returns nothing' do
+ expect(resolve_objects).to be_nil
+ end
+ end
+ end
+ end
+
+ describe 'attempting to retrieve an object not visible at this version' do
+ let(:design) { design_d }
+
+ %i[design_at_version_id design_id filename].each do |arg|
+ describe "passing #{arg}" do
+ let(:args) { all_singular_args.slice(arg) }
+
+ it 'does not find the design' do
+ expect(resolve_objects).to be_nil
+ end
+ end
+ end
+ end
+ end
+
+ def resolve_objects
+ resolve(described_class, obj: version, args: args, ctx: gql_context)
+ end
+
+ def dav(design)
+ build(:design_at_version, design: design, version: version)
+ end
+end
diff --git a/spec/graphql/resolvers/design_management/version/designs_at_version_resolver_spec.rb b/spec/graphql/resolvers/design_management/version/designs_at_version_resolver_spec.rb
new file mode 100644
index 00000000000..123b26862d0
--- /dev/null
+++ b/spec/graphql/resolvers/design_management/version/designs_at_version_resolver_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::DesignManagement::Version::DesignsAtVersionResolver do
+ include GraphqlHelpers
+
+ include_context 'four designs in three versions'
+
+ let_it_be(:current_user) { authorized_user }
+ let(:gql_context) { { current_user: current_user } }
+
+ let(:version) { third_version }
+
+ describe '.single' do
+ let(:single) { ::Resolvers::DesignManagement::Version::DesignAtVersionResolver }
+
+ it 'returns the single context resolver' do
+ expect(described_class.single).to eq(single)
+ end
+ end
+
+ describe '#resolve' do
+ let(:args) { {} }
+
+ context 'when the user cannot see designs' do
+ let(:current_user) { create(:user) }
+
+ it 'returns nothing' do
+ expect(resolve_objects).to be_empty
+ end
+ end
+
+ context 'for the current version' do
+ it 'returns all designs visible at that version' do
+ expect(resolve_objects).to contain_exactly(dav(design_a), dav(design_b), dav(design_c))
+ end
+ end
+
+ context 'for a previous version with more objects' do
+ let(:version) { second_version }
+
+ it 'returns objects that were later deleted' do
+ expect(resolve_objects).to contain_exactly(dav(design_a), dav(design_b), dav(design_c), dav(design_d))
+ end
+ end
+
+ context 'for a previous version with fewer objects' do
+ let(:version) { first_version }
+
+ it 'does not return objects that were later created' do
+ expect(resolve_objects).to contain_exactly(dav(design_a))
+ end
+ end
+
+ describe 'filtering' do
+ describe 'by filename' do
+ let(:red_herring) { create(:design, issue: create(:issue, project: project)) }
+ let(:args) { { filenames: [design_b.filename, red_herring.filename] } }
+
+ it 'resolves to just the relevant design' do
+ create(:design, issue: create(:issue, project: project), filename: design_b.filename)
+
+ expect(resolve_objects).to contain_exactly(dav(design_b))
+ end
+ end
+
+ describe 'by id' do
+ let(:red_herring) { create(:design, issue: create(:issue, project: project)) }
+ let(:args) { { ids: [design_a, red_herring].map { |x| global_id_of(x) } } }
+
+ it 'resolves to just the relevant design, ignoring objects on other issues' do
+ expect(resolve_objects).to contain_exactly(dav(design_a))
+ end
+ end
+ end
+ end
+
+ def resolve_objects
+ resolve(described_class, obj: version, args: args, ctx: gql_context)
+ end
+
+ def dav(design)
+ build(:design_at_version, design: design, version: version)
+ end
+end
diff --git a/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb b/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb
new file mode 100644
index 00000000000..ef50598d241
--- /dev/null
+++ b/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::DesignManagement::VersionInCollectionResolver do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+
+ let(:resolver) { described_class }
+
+ describe '#resolve' do
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:first_version) { create(:design_version, issue: issue) }
+
+ let(:project) { issue.project }
+ let(:params) { {} }
+
+ before do
+ enable_design_management
+ project.add_developer(current_user)
+ end
+
+ let(:appropriate_error) { ::Gitlab::Graphql::Errors::ArgumentError }
+
+ subject(:result) { resolve_version(issue.design_collection) }
+
+ context 'Neither id nor sha is passed as parameters' do
+ it 'raises an appropriate error' do
+ expect { result }.to raise_error(appropriate_error)
+ end
+ end
+
+ context 'we pass an id' do
+ let(:params) { { id: global_id_of(first_version) } }
+
+ it { is_expected.to eq(first_version) }
+ end
+
+ context 'we pass a sha' do
+ let(:params) { { sha: first_version.sha } }
+
+ it { is_expected.to eq(first_version) }
+ end
+
+ context 'we pass an inconsistent mixture of sha and version id' do
+ let(:params) { { sha: first_version.sha, id: global_id_of(create(:design_version)) } }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'we pass the id of something that is not a design_version' do
+ let(:params) { { id: global_id_of(project) } }
+
+ it 'raises an appropriate error' do
+ expect { result }.to raise_error(appropriate_error)
+ end
+ end
+ end
+
+ def resolve_version(obj, context = { current_user: current_user })
+ resolve(resolver, obj: obj, args: params, ctx: context)
+ end
+end
diff --git a/spec/graphql/resolvers/design_management/version_resolver_spec.rb b/spec/graphql/resolvers/design_management/version_resolver_spec.rb
new file mode 100644
index 00000000000..e7c09351204
--- /dev/null
+++ b/spec/graphql/resolvers/design_management/version_resolver_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::DesignManagement::VersionResolver do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:version) { create(:design_version, issue: issue) }
+ let_it_be(:developer) { create(:user) }
+
+ let(:project) { issue.project }
+ let(:params) { { id: global_id_of(version) } }
+
+ before do
+ enable_design_management
+ project.add_developer(developer)
+ end
+
+ context 'the current user is not authorized' do
+ let(:current_user) { create(:user) }
+
+ it 'raises an error on resolution' do
+ expect { resolve_version }.to raise_error(::Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'the current user is authorized' do
+ let(:current_user) { developer }
+
+ context 'the id parameter is provided' do
+ it 'returns the specified version' do
+ expect(resolve_version).to eq(version)
+ end
+ end
+ end
+
+ def resolve_version
+ resolve(described_class, obj: nil, args: params, ctx: { current_user: current_user })
+ end
+end
diff --git a/spec/graphql/resolvers/design_management/versions_resolver_spec.rb b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb
new file mode 100644
index 00000000000..d5bab025e45
--- /dev/null
+++ b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::DesignManagement::VersionsResolver do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+
+ describe '#resolve' do
+ let(:resolver) { described_class }
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:authorized_user) { create(:user) }
+ let_it_be(:first_version) { create(:design_version, issue: issue) }
+ let_it_be(:other_version) { create(:design_version, issue: issue) }
+ let_it_be(:first_design) { create(:design, issue: issue, versions: [first_version, other_version]) }
+ let_it_be(:other_design) { create(:design, :with_versions, issue: issue) }
+
+ let(:project) { issue.project }
+ let(:params) { {} }
+ let(:current_user) { authorized_user }
+ let(:parent_args) { { irrelevant: 1.2 } }
+ let(:parent) { double('Parent', parent: nil, irep_node: double(arguments: parent_args)) }
+
+ before do
+ enable_design_management
+ project.add_developer(authorized_user)
+ end
+
+ shared_examples 'a source of versions' do
+ subject(:result) { resolve_versions(object) }
+
+ let_it_be(:all_versions) { object.versions.ordered }
+
+ context 'when the user is not authorized' do
+ let(:current_user) { create(:user) }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'without constraints' do
+ it 'returns the ordered versions' do
+ expect(result).to eq(all_versions)
+ end
+ end
+
+ context 'when constrained' do
+ let_it_be(:matching) { all_versions.earlier_or_equal_to(first_version) }
+
+ shared_examples 'a query for all_versions up to the first_version' do
+ it { is_expected.to eq(matching) }
+ end
+
+ context 'by earlier_or_equal_to_id' do
+ let(:params) { { id: global_id_of(first_version) } }
+
+ it_behaves_like 'a query for all_versions up to the first_version'
+ end
+
+ context 'by earlier_or_equal_to_sha' do
+ let(:params) { { sha: first_version.sha } }
+
+ it_behaves_like 'a query for all_versions up to the first_version'
+ end
+
+ context 'by earlier_or_equal_to_sha AND earlier_or_equal_to_id' do
+ context 'and they match' do
+ # This usage is rather dumb, but so long as they match, this will
+ # return successfully
+ let(:params) do
+ {
+ sha: first_version.sha,
+ id: global_id_of(first_version)
+ }
+ end
+
+ it_behaves_like 'a query for all_versions up to the first_version'
+ end
+
+ context 'and they do not match' do
+ let(:params) do
+ {
+ sha: first_version.sha,
+ id: global_id_of(other_version)
+ }
+ end
+
+ it 'raises a suitable error' do
+ expect { result }.to raise_error(GraphQL::ExecutionError)
+ end
+ end
+ end
+
+ context 'by at_version in parent' do
+ let(:parent_args) { { atVersion: global_id_of(first_version) } }
+
+ it_behaves_like 'a query for all_versions up to the first_version'
+ end
+ end
+ end
+
+ describe 'a design collection' do
+ let_it_be(:object) { DesignManagement::DesignCollection.new(issue) }
+
+ it_behaves_like 'a source of versions'
+ end
+
+ describe 'a design' do
+ let_it_be(:object) { first_design }
+
+ it_behaves_like 'a source of versions'
+ end
+
+ def resolve_versions(obj, context = { current_user: current_user })
+ eager_resolve(resolver, obj: obj, args: params.merge(parent: parent), ctx: context)
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb
index 53e0a9e3724..b7cc9bc6d71 100644
--- a/spec/graphql/resolvers/issues_resolver_spec.rb
+++ b/spec/graphql/resolvers/issues_resolver_spec.rb
@@ -125,12 +125,11 @@ describe Resolvers::IssuesResolver do
end
context 'when sorting by due date' do
- let(:project) { create(:project) }
-
- let!(:due_issue1) { create(:issue, project: project, due_date: 3.days.from_now) }
- let!(:due_issue2) { create(:issue, project: project, due_date: nil) }
- let!(:due_issue3) { create(:issue, project: project, due_date: 2.days.ago) }
- let!(:due_issue4) { create(:issue, project: project, due_date: nil) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:due_issue1) { create(:issue, project: project, due_date: 3.days.from_now) }
+ let_it_be(:due_issue2) { create(:issue, project: project, due_date: nil) }
+ let_it_be(:due_issue3) { create(:issue, project: project, due_date: 2.days.ago) }
+ let_it_be(:due_issue4) { create(:issue, project: project, due_date: nil) }
it 'sorts issues ascending' do
expect(resolve_issues(sort: :due_date_asc)).to eq [due_issue3, due_issue1, due_issue4, due_issue2]
@@ -142,17 +141,72 @@ describe Resolvers::IssuesResolver do
end
context 'when sorting by relative position' do
- let(:project) { create(:project) }
-
- let!(:relative_issue1) { create(:issue, project: project, relative_position: 2000) }
- let!(:relative_issue2) { create(:issue, project: project, relative_position: nil) }
- let!(:relative_issue3) { create(:issue, project: project, relative_position: 1000) }
- let!(:relative_issue4) { create(:issue, project: project, relative_position: nil) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:relative_issue1) { create(:issue, project: project, relative_position: 2000) }
+ let_it_be(:relative_issue2) { create(:issue, project: project, relative_position: nil) }
+ let_it_be(:relative_issue3) { create(:issue, project: project, relative_position: 1000) }
+ let_it_be(:relative_issue4) { create(:issue, project: project, relative_position: nil) }
it 'sorts issues ascending' do
expect(resolve_issues(sort: :relative_position_asc)).to eq [relative_issue3, relative_issue1, relative_issue4, relative_issue2]
end
end
+
+ context 'when sorting by priority' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:early_milestone) { create(:milestone, project: project, due_date: 10.days.from_now) }
+ let_it_be(:late_milestone) { create(:milestone, project: project, due_date: 30.days.from_now) }
+ let_it_be(:priority_label1) { create(:label, project: project, priority: 1) }
+ let_it_be(:priority_label2) { create(:label, project: project, priority: 5) }
+ let_it_be(:priority_issue1) { create(:issue, project: project, labels: [priority_label1], milestone: late_milestone) }
+ let_it_be(:priority_issue2) { create(:issue, project: project, labels: [priority_label2]) }
+ let_it_be(:priority_issue3) { create(:issue, project: project, milestone: early_milestone) }
+ let_it_be(:priority_issue4) { create(:issue, project: project) }
+
+ it 'sorts issues ascending' do
+ expect(resolve_issues(sort: :priority_asc).items).to eq([priority_issue3, priority_issue1, priority_issue2, priority_issue4])
+ end
+
+ it 'sorts issues descending' do
+ expect(resolve_issues(sort: :priority_desc).items).to eq([priority_issue1, priority_issue3, priority_issue2, priority_issue4])
+ end
+ end
+
+ context 'when sorting by label priority' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:label1) { create(:label, project: project, priority: 1) }
+ let_it_be(:label2) { create(:label, project: project, priority: 5) }
+ let_it_be(:label3) { create(:label, project: project, priority: 10) }
+ let_it_be(:label_issue1) { create(:issue, project: project, labels: [label1]) }
+ let_it_be(:label_issue2) { create(:issue, project: project, labels: [label2]) }
+ let_it_be(:label_issue3) { create(:issue, project: project, labels: [label1, label3]) }
+ let_it_be(:label_issue4) { create(:issue, project: project) }
+
+ it 'sorts issues ascending' do
+ expect(resolve_issues(sort: :label_priority_asc).items).to eq([label_issue3, label_issue1, label_issue2, label_issue4])
+ end
+
+ it 'sorts issues descending' do
+ expect(resolve_issues(sort: :label_priority_desc).items).to eq([label_issue2, label_issue3, label_issue1, label_issue4])
+ end
+ end
+
+ context 'when sorting by milestone due date' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:early_milestone) { create(:milestone, project: project, due_date: 10.days.from_now) }
+ let_it_be(:late_milestone) { create(:milestone, project: project, due_date: 30.days.from_now) }
+ let_it_be(:milestone_issue1) { create(:issue, project: project) }
+ let_it_be(:milestone_issue2) { create(:issue, project: project, milestone: early_milestone) }
+ let_it_be(:milestone_issue3) { create(:issue, project: project, milestone: late_milestone) }
+
+ it 'sorts issues ascending' do
+ expect(resolve_issues(sort: :milestone_due_asc).items).to eq([milestone_issue2, milestone_issue3, milestone_issue1])
+ end
+
+ it 'sorts issues descending' do
+ expect(resolve_issues(sort: :milestone_due_desc).items).to eq([milestone_issue3, milestone_issue2, milestone_issue1])
+ end
+ end
end
it 'returns issues user can see' do
diff --git a/spec/graphql/resolvers/milestone_resolver_spec.rb b/spec/graphql/resolvers/milestone_resolver_spec.rb
index 297130c2027..8e2c67fdc03 100644
--- a/spec/graphql/resolvers/milestone_resolver_spec.rb
+++ b/spec/graphql/resolvers/milestone_resolver_spec.rb
@@ -8,14 +8,14 @@ describe Resolvers::MilestoneResolver do
describe '#resolve' do
let_it_be(:current_user) { create(:user) }
+ def resolve_group_milestones(args = {}, context = { current_user: current_user })
+ resolve(described_class, obj: group, args: args, ctx: context)
+ end
+
context 'for group milestones' do
let_it_be(:now) { Time.now }
let_it_be(:group) { create(:group, :private) }
- def resolve_group_milestones(args = {}, context = { current_user: current_user })
- resolve(described_class, obj: group, args: args, ctx: context)
- end
-
before do
group.add_developer(current_user)
end
@@ -89,5 +89,25 @@ describe Resolvers::MilestoneResolver do
end
end
end
+
+ context 'when including descendant milestones in a public group' do
+ let_it_be(:group) { create(:group, :public) }
+ let(:args) { { include_descendants: true } }
+
+ it 'finds milestones only in accessible projects and groups' do
+ accessible_group = create(:group, :private, parent: group)
+ accessible_project = create(:project, group: accessible_group)
+ accessible_group.add_developer(current_user)
+ inaccessible_group = create(:group, :private, parent: group)
+ inaccessible_project = create(:project, :private, group: group)
+ milestone1 = create(:milestone, group: group)
+ milestone2 = create(:milestone, group: accessible_group)
+ milestone3 = create(:milestone, project: accessible_project)
+ create(:milestone, group: inaccessible_group)
+ create(:milestone, project: inaccessible_project)
+
+ expect(resolve_group_milestones(args)).to match_array([milestone1, milestone2, milestone3])
+ end
+ end
end
end
diff --git a/spec/graphql/resolvers/projects/jira_imports_resolver_spec.rb b/spec/graphql/resolvers/projects/jira_imports_resolver_spec.rb
index 47889126531..7146bfb441b 100644
--- a/spec/graphql/resolvers/projects/jira_imports_resolver_spec.rb
+++ b/spec/graphql/resolvers/projects/jira_imports_resolver_spec.rb
@@ -16,7 +16,7 @@ describe Resolvers::Projects::JiraImportsResolver do
context 'when anonymous user' do
let(:current_user) { nil }
- it_behaves_like 'no jira import access'
+ it_behaves_like 'no Jira import access'
end
end
@@ -25,7 +25,7 @@ describe Resolvers::Projects::JiraImportsResolver do
project.add_guest(user)
end
- it_behaves_like 'no jira import data present'
+ it_behaves_like 'no Jira import data present'
it 'does not raise access error' do
expect do
@@ -47,14 +47,14 @@ describe Resolvers::Projects::JiraImportsResolver do
stub_feature_flags(jira_issue_import: false)
end
- it_behaves_like 'no jira import access'
+ it_behaves_like 'no Jira import access'
end
context 'when user cannot read Jira imports' do
context 'when anonymous user' do
let(:current_user) { nil }
- it_behaves_like 'no jira import access'
+ it_behaves_like 'no Jira import access'
end
end
diff --git a/spec/graphql/resolvers/projects_resolver_spec.rb b/spec/graphql/resolvers/projects_resolver_spec.rb
new file mode 100644
index 00000000000..73ff99a2520
--- /dev/null
+++ b/spec/graphql/resolvers/projects_resolver_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::ProjectsResolver do
+ include GraphqlHelpers
+
+ describe '#resolve' do
+ subject { resolve(described_class, obj: nil, args: filters, ctx: { current_user: current_user }) }
+
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:other_project) { create(:project, :public) }
+ let_it_be(:private_project) { create(:project, :private) }
+ let_it_be(:other_private_project) { create(:project, :private) }
+
+ let_it_be(:user) { create(:user) }
+
+ let(:filters) { {} }
+
+ before_all do
+ project.add_developer(user)
+ private_project.add_developer(user)
+ end
+
+ context 'when user is not logged in' do
+ let(:current_user) { nil }
+
+ context 'when no filters are applied' do
+ it 'returns all public projects' do
+ is_expected.to contain_exactly(project, other_project)
+ end
+
+ context 'when search filter is provided' do
+ let(:filters) { { search: project.name } }
+
+ it 'returns matching project' do
+ is_expected.to contain_exactly(project)
+ end
+ end
+
+ context 'when membership filter is provided' do
+ let(:filters) { { membership: true } }
+
+ it 'returns empty list' do
+ is_expected.to be_empty
+ end
+ end
+ end
+ end
+
+ context 'when user is logged in' do
+ let(:current_user) { user }
+
+ context 'when no filters are applied' do
+ it 'returns all visible projects for the user' do
+ is_expected.to contain_exactly(project, other_project, private_project)
+ end
+
+ context 'when search filter is provided' do
+ let(:filters) { { search: project.name } }
+
+ it 'returns matching project' do
+ is_expected.to contain_exactly(project)
+ end
+ end
+
+ context 'when membership filter is provided' do
+ let(:filters) { { membership: true } }
+
+ it 'returns projects that user is member of' do
+ is_expected.to contain_exactly(project, private_project)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/release_resolver_spec.rb b/spec/graphql/resolvers/release_resolver_spec.rb
new file mode 100644
index 00000000000..71aa4bbb439
--- /dev/null
+++ b/spec/graphql/resolvers/release_resolver_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::ReleaseResolver do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:release) { create(:release, project: project) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:public_user) { create(:user) }
+
+ let(:args) { { tag_name: release.tag } }
+
+ before do
+ project.add_developer(developer)
+ end
+
+ describe '#resolve' do
+ context 'when the user does not have access to the project' do
+ let(:current_user) { public_user }
+
+ it 'returns nil' do
+ expect(resolve_release).to be_nil
+ end
+ end
+
+ context "when the user has full access to the project's releases" do
+ let(:current_user) { developer }
+
+ it 'returns the release associated with the specified tag' do
+ expect(resolve_release).to eq(release)
+ end
+
+ context 'when no tag_name argument was passed' do
+ let(:args) { {} }
+
+ it 'raises an error' do
+ expect { resolve_release }.to raise_error(ArgumentError, "missing keyword: tag_name")
+ end
+ end
+ end
+ end
+
+ private
+
+ def resolve_release
+ context = { current_user: current_user }
+ resolve(described_class, obj: project, args: args, ctx: context)
+ end
+end
diff --git a/spec/graphql/resolvers/releases_resolver_spec.rb b/spec/graphql/resolvers/releases_resolver_spec.rb
new file mode 100644
index 00000000000..9de539b417a
--- /dev/null
+++ b/spec/graphql/resolvers/releases_resolver_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Resolvers::ReleasesResolver do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:release_v1) { create(:release, project: project, tag: 'v1.0.0') }
+ let_it_be(:release_v2) { create(:release, project: project, tag: 'v2.0.0') }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:public_user) { create(:user) }
+
+ before do
+ project.add_developer(developer)
+ end
+
+ describe '#resolve' do
+ context 'when the user does not have access to the project' do
+ let(:current_user) { public_user }
+
+ it 'returns an empty array' do
+ expect(resolve_releases).to eq([])
+ end
+ end
+
+ context "when the user has full access to the project's releases" do
+ let(:current_user) { developer }
+
+ it 'returns all releases associated to the project' do
+ expect(resolve_releases).to eq([release_v1, release_v2])
+ end
+ end
+ end
+
+ private
+
+ def resolve_releases
+ context = { current_user: current_user }
+ resolve(described_class, obj: project, args: {}, ctx: context)
+ end
+end
diff --git a/spec/graphql/types/alert_management/alert_status_count_type_spec.rb b/spec/graphql/types/alert_management/alert_status_count_type_spec.rb
new file mode 100644
index 00000000000..1c56028425e
--- /dev/null
+++ b/spec/graphql/types/alert_management/alert_status_count_type_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GitlabSchema.types['AlertManagementAlertStatusCountsType'] do
+ specify { expect(described_class.graphql_name).to eq('AlertManagementAlertStatusCountsType') }
+
+ it 'exposes the expected fields' do
+ expected_fields = %i[
+ all
+ open
+ triggered
+ acknowledged
+ resolved
+ ignored
+ ]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/alert_management/alert_type_spec.rb b/spec/graphql/types/alert_management/alert_type_spec.rb
new file mode 100644
index 00000000000..9c326f30e3c
--- /dev/null
+++ b/spec/graphql/types/alert_management/alert_type_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GitlabSchema.types['AlertManagementAlert'] do
+ specify { expect(described_class.graphql_name).to eq('AlertManagementAlert') }
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_alert_management_alert) }
+
+ it 'exposes the expected fields' do
+ expected_fields = %i[
+ iid
+ issue_iid
+ title
+ description
+ severity
+ status
+ service
+ monitoring_tool
+ hosts
+ started_at
+ ended_at
+ event_count
+ details
+ created_at
+ updated_at
+ ]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/alert_management/severity_enum_spec.rb b/spec/graphql/types/alert_management/severity_enum_spec.rb
new file mode 100644
index 00000000000..ca5aa826fe5
--- /dev/null
+++ b/spec/graphql/types/alert_management/severity_enum_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GitlabSchema.types['AlertManagementSeverity'] do
+ specify { expect(described_class.graphql_name).to eq('AlertManagementSeverity') }
+
+ it 'exposes all the severity values' do
+ expect(described_class.values.keys).to include(*%w[CRITICAL HIGH MEDIUM LOW INFO UNKNOWN])
+ end
+end
diff --git a/spec/graphql/types/alert_management/status_enum_spec.rb b/spec/graphql/types/alert_management/status_enum_spec.rb
new file mode 100644
index 00000000000..240d8863c97
--- /dev/null
+++ b/spec/graphql/types/alert_management/status_enum_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GitlabSchema.types['AlertManagementStatus'] do
+ specify { expect(described_class.graphql_name).to eq('AlertManagementStatus') }
+
+ describe 'statuses' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:status_name, :status_value) do
+ 'TRIGGERED' | 0
+ 'ACKNOWLEDGED' | 1
+ 'RESOLVED' | 2
+ 'IGNORED' | 3
+ end
+
+ with_them do
+ it 'exposes a status with the correct value' do
+ expect(described_class.values[status_name].value).to eq(status_value)
+ end
+ end
+ end
+end
diff --git a/spec/graphql/types/award_emojis/award_emoji_type_spec.rb b/spec/graphql/types/award_emojis/award_emoji_type_spec.rb
index de5ece3b749..4e06329506d 100644
--- a/spec/graphql/types/award_emojis/award_emoji_type_spec.rb
+++ b/spec/graphql/types/award_emojis/award_emoji_type_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
describe GitlabSchema.types['AwardEmoji'] do
- it { expect(described_class.graphql_name).to eq('AwardEmoji') }
+ specify { expect(described_class.graphql_name).to eq('AwardEmoji') }
- it { expect(described_class).to require_graphql_authorizations(:read_emoji) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_emoji) }
- it { expect(described_class).to have_graphql_fields(:description, :unicode_version, :emoji, :name, :unicode, :user) }
+ specify { expect(described_class).to have_graphql_fields(:description, :unicode_version, :emoji, :name, :unicode, :user) }
end
diff --git a/spec/graphql/types/blob_viewers/type_enum_spec.rb b/spec/graphql/types/blob_viewers/type_enum_spec.rb
index 7bd4352f388..09664382af9 100644
--- a/spec/graphql/types/blob_viewers/type_enum_spec.rb
+++ b/spec/graphql/types/blob_viewers/type_enum_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe Types::BlobViewers::TypeEnum do
- it { expect(described_class.graphql_name).to eq('BlobViewersType') }
+ specify { expect(described_class.graphql_name).to eq('BlobViewersType') }
it 'exposes all tree entry types' do
expect(described_class.values.keys).to include(*%w[rich simple auxiliary])
diff --git a/spec/graphql/types/board_list_type_spec.rb b/spec/graphql/types/board_list_type_spec.rb
new file mode 100644
index 00000000000..69597fc9617
--- /dev/null
+++ b/spec/graphql/types/board_list_type_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GitlabSchema.types['BoardList'] do
+ specify { expect(described_class.graphql_name).to eq('BoardList') }
+
+ it 'has specific fields' do
+ expected_fields = %w[id list_type position label]
+
+ expect(described_class).to include_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/board_type_spec.rb b/spec/graphql/types/board_type_spec.rb
index 1ca4bf18b57..5d87a1757b5 100644
--- a/spec/graphql/types/board_type_spec.rb
+++ b/spec/graphql/types/board_type_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
describe GitlabSchema.types['Board'] do
- it { expect(described_class.graphql_name).to eq('Board') }
+ specify { expect(described_class.graphql_name).to eq('Board') }
- it { expect(described_class).to require_graphql_authorizations(:read_board) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_board) }
it 'has specific fields' do
expected_fields = %w[id name]
diff --git a/spec/graphql/types/branch_type_spec.rb b/spec/graphql/types/branch_type_spec.rb
new file mode 100644
index 00000000000..f58b514116d
--- /dev/null
+++ b/spec/graphql/types/branch_type_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GitlabSchema.types['Branch'] do
+ it { expect(described_class.graphql_name).to eq('Branch') }
+
+ it { expect(described_class).to have_graphql_fields(:name, :commit) }
+end
diff --git a/spec/graphql/types/ci/detailed_status_type_spec.rb b/spec/graphql/types/ci/detailed_status_type_spec.rb
index 169a03c770b..c62c8f23728 100644
--- a/spec/graphql/types/ci/detailed_status_type_spec.rb
+++ b/spec/graphql/types/ci/detailed_status_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe Types::Ci::DetailedStatusType do
- it { expect(described_class.graphql_name).to eq('DetailedStatus') }
+ specify { expect(described_class.graphql_name).to eq('DetailedStatus') }
it "has all fields" do
expect(described_class).to have_graphql_fields(:group, :icon, :favicon,
diff --git a/spec/graphql/types/ci/pipeline_type_spec.rb b/spec/graphql/types/ci/pipeline_type_spec.rb
index 2fafc1bc13f..d56cff12105 100644
--- a/spec/graphql/types/ci/pipeline_type_spec.rb
+++ b/spec/graphql/types/ci/pipeline_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe Types::Ci::PipelineType do
- it { expect(described_class.graphql_name).to eq('Pipeline') }
+ specify { expect(described_class.graphql_name).to eq('Pipeline') }
- it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Ci::Pipeline) }
+ specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Ci::Pipeline) }
end
diff --git a/spec/graphql/types/commit_type_spec.rb b/spec/graphql/types/commit_type_spec.rb
index f5f99229f3a..88b450e3924 100644
--- a/spec/graphql/types/commit_type_spec.rb
+++ b/spec/graphql/types/commit_type_spec.rb
@@ -3,13 +3,13 @@
require 'spec_helper'
describe GitlabSchema.types['Commit'] do
- it { expect(described_class.graphql_name).to eq('Commit') }
+ specify { expect(described_class.graphql_name).to eq('Commit') }
- it { expect(described_class).to require_graphql_authorizations(:download_code) }
+ specify { expect(described_class).to require_graphql_authorizations(:download_code) }
it 'contains attributes related to commit' do
expect(described_class).to have_graphql_fields(
- :id, :sha, :title, :description, :message, :authored_date,
+ :id, :sha, :title, :description, :message, :title_html, :authored_date,
:author_name, :author_gravatar, :author, :web_url, :latest_pipeline,
:pipelines, :signature_html
)
diff --git a/spec/graphql/types/design_management/design_at_version_type_spec.rb b/spec/graphql/types/design_management/design_at_version_type_spec.rb
new file mode 100644
index 00000000000..1453d73d59c
--- /dev/null
+++ b/spec/graphql/types/design_management/design_at_version_type_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GitlabSchema.types['DesignAtVersion'] do
+ it_behaves_like 'a GraphQL type with design fields' do
+ let(:extra_design_fields) { %i[version design] }
+ let_it_be(:design) { create(:design, :with_versions) }
+ let(:object_id) do
+ version = design.versions.first
+ GitlabSchema.id_from_object(create(:design_at_version, design: design, version: version))
+ end
+ let_it_be(:object_id_b) { GitlabSchema.id_from_object(create(:design_at_version)) }
+ let(:object_type) { ::Types::DesignManagement::DesignAtVersionType }
+ end
+end
diff --git a/spec/graphql/types/design_management/design_collection_type_spec.rb b/spec/graphql/types/design_management/design_collection_type_spec.rb
new file mode 100644
index 00000000000..65150f0971d
--- /dev/null
+++ b/spec/graphql/types/design_management/design_collection_type_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GitlabSchema.types['DesignCollection'] do
+ it { expect(described_class).to require_graphql_authorizations(:read_design) }
+
+ it 'has the expected fields' do
+ expected_fields = %i[project issue designs versions version designAtVersion design]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/design_management/design_type_spec.rb b/spec/graphql/types/design_management/design_type_spec.rb
new file mode 100644
index 00000000000..75b4cd66d5e
--- /dev/null
+++ b/spec/graphql/types/design_management/design_type_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GitlabSchema.types['Design'] do
+ it_behaves_like 'a GraphQL type with design fields' do
+ let(:extra_design_fields) { %i[notes 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)) }
+ let(:object_type) { ::Types::DesignManagement::DesignType }
+ end
+end
diff --git a/spec/graphql/types/design_management/design_version_event_enum_spec.rb b/spec/graphql/types/design_management/design_version_event_enum_spec.rb
new file mode 100644
index 00000000000..a65f1bb5990
--- /dev/null
+++ b/spec/graphql/types/design_management/design_version_event_enum_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GitlabSchema.types['DesignVersionEvent'] do
+ it { expect(described_class.graphql_name).to eq('DesignVersionEvent') }
+
+ it 'exposes the correct event states' do
+ expect(described_class.values.keys).to include(*%w[CREATION MODIFICATION DELETION NONE])
+ end
+end
diff --git a/spec/graphql/types/design_management/version_type_spec.rb b/spec/graphql/types/design_management/version_type_spec.rb
new file mode 100644
index 00000000000..3317c4c6571
--- /dev/null
+++ b/spec/graphql/types/design_management/version_type_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GitlabSchema.types['DesignVersion'] do
+ it { expect(described_class).to require_graphql_authorizations(:read_design) }
+
+ it 'has the expected fields' do
+ expected_fields = %i[id sha designs design_at_version designs_at_version]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/design_management_type_spec.rb b/spec/graphql/types/design_management_type_spec.rb
new file mode 100644
index 00000000000..a6204f20f23
--- /dev/null
+++ b/spec/graphql/types/design_management_type_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GitlabSchema.types['DesignManagement'] do
+ it { expect(described_class).to have_graphql_fields(:version, :design_at_version) }
+end
diff --git a/spec/graphql/types/diff_refs_type_spec.rb b/spec/graphql/types/diff_refs_type_spec.rb
index a6ead27455f..3165e642452 100644
--- a/spec/graphql/types/diff_refs_type_spec.rb
+++ b/spec/graphql/types/diff_refs_type_spec.rb
@@ -3,11 +3,11 @@
require 'spec_helper'
describe GitlabSchema.types['DiffRefs'] do
- it { expect(described_class.graphql_name).to eq('DiffRefs') }
+ specify { expect(described_class.graphql_name).to eq('DiffRefs') }
- it { expect(described_class).to have_graphql_fields(:head_sha, :base_sha, :start_sha).only }
+ specify { expect(described_class).to have_graphql_fields(:head_sha, :base_sha, :start_sha).only }
- it { expect(described_class.fields['headSha'].type).to be_non_null }
- it { expect(described_class.fields['baseSha'].type).not_to be_non_null }
- it { expect(described_class.fields['startSha'].type).to be_non_null }
+ specify { expect(described_class.fields['headSha'].type).to be_non_null }
+ specify { expect(described_class.fields['baseSha'].type).not_to be_non_null }
+ specify { expect(described_class.fields['startSha'].type).to be_non_null }
end
diff --git a/spec/graphql/types/environment_type_spec.rb b/spec/graphql/types/environment_type_spec.rb
index 24a8bddfa6a..0e5cbac05df 100644
--- a/spec/graphql/types/environment_type_spec.rb
+++ b/spec/graphql/types/environment_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe GitlabSchema.types['Environment'] do
- it { expect(described_class.graphql_name).to eq('Environment') }
+ specify { expect(described_class.graphql_name).to eq('Environment') }
it 'has the expected fields' do
expected_fields = %w[
@@ -13,5 +13,5 @@ describe GitlabSchema.types['Environment'] do
expect(described_class).to have_graphql_fields(*expected_fields)
end
- it { expect(described_class).to require_graphql_authorizations(:read_environment) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_environment) }
end
diff --git a/spec/graphql/types/error_tracking/sentry_detailed_error_type_spec.rb b/spec/graphql/types/error_tracking/sentry_detailed_error_type_spec.rb
index 44652f831b5..0a094e9e188 100644
--- a/spec/graphql/types/error_tracking/sentry_detailed_error_type_spec.rb
+++ b/spec/graphql/types/error_tracking/sentry_detailed_error_type_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
describe GitlabSchema.types['SentryDetailedError'] do
- it { expect(described_class.graphql_name).to eq('SentryDetailedError') }
+ specify { expect(described_class.graphql_name).to eq('SentryDetailedError') }
- it { expect(described_class).to require_graphql_authorizations(:read_sentry_issue) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_sentry_issue) }
it 'exposes the expected fields' do
expected_fields = %i[
diff --git a/spec/graphql/types/error_tracking/sentry_error_collection_type_spec.rb b/spec/graphql/types/error_tracking/sentry_error_collection_type_spec.rb
index 20ec31391d8..793da2db960 100644
--- a/spec/graphql/types/error_tracking/sentry_error_collection_type_spec.rb
+++ b/spec/graphql/types/error_tracking/sentry_error_collection_type_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
describe GitlabSchema.types['SentryErrorCollection'] do
- it { expect(described_class.graphql_name).to eq('SentryErrorCollection') }
+ specify { expect(described_class.graphql_name).to eq('SentryErrorCollection') }
- it { expect(described_class).to require_graphql_authorizations(:read_sentry_issue) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_sentry_issue) }
it 'exposes the expected fields' do
expected_fields = %i[
diff --git a/spec/graphql/types/error_tracking/sentry_error_stack_trace_entry_type_spec.rb b/spec/graphql/types/error_tracking/sentry_error_stack_trace_entry_type_spec.rb
index 05cc2ca7612..b65398fccc9 100644
--- a/spec/graphql/types/error_tracking/sentry_error_stack_trace_entry_type_spec.rb
+++ b/spec/graphql/types/error_tracking/sentry_error_stack_trace_entry_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe GitlabSchema.types['SentryErrorStackTraceEntry'] do
- it { expect(described_class.graphql_name).to eq('SentryErrorStackTraceEntry') }
+ specify { expect(described_class.graphql_name).to eq('SentryErrorStackTraceEntry') }
it 'exposes the expected fields' do
expected_fields = %i[
diff --git a/spec/graphql/types/error_tracking/sentry_error_stack_trace_type_spec.rb b/spec/graphql/types/error_tracking/sentry_error_stack_trace_type_spec.rb
index 2a422228f72..2cec8865764 100644
--- a/spec/graphql/types/error_tracking/sentry_error_stack_trace_type_spec.rb
+++ b/spec/graphql/types/error_tracking/sentry_error_stack_trace_type_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
describe GitlabSchema.types['SentryErrorStackTrace'] do
- it { expect(described_class.graphql_name).to eq('SentryErrorStackTrace') }
+ specify { expect(described_class.graphql_name).to eq('SentryErrorStackTrace') }
- it { expect(described_class).to require_graphql_authorizations(:read_sentry_issue) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_sentry_issue) }
it 'exposes the expected fields' do
expected_fields = %i[
diff --git a/spec/graphql/types/error_tracking/sentry_error_type_spec.rb b/spec/graphql/types/error_tracking/sentry_error_type_spec.rb
index 4676d91ef9c..f8cc801e35e 100644
--- a/spec/graphql/types/error_tracking/sentry_error_type_spec.rb
+++ b/spec/graphql/types/error_tracking/sentry_error_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe GitlabSchema.types['SentryError'] do
- it { expect(described_class.graphql_name).to eq('SentryError') }
+ specify { expect(described_class.graphql_name).to eq('SentryError') }
it 'exposes the expected fields' do
expected_fields = %i[
diff --git a/spec/graphql/types/grafana_integration_type_spec.rb b/spec/graphql/types/grafana_integration_type_spec.rb
index ac26911acbf..429b5bdffe6 100644
--- a/spec/graphql/types/grafana_integration_type_spec.rb
+++ b/spec/graphql/types/grafana_integration_type_spec.rb
@@ -14,9 +14,9 @@ describe GitlabSchema.types['GrafanaIntegration'] do
]
end
- it { expect(described_class.graphql_name).to eq('GrafanaIntegration') }
+ specify { expect(described_class.graphql_name).to eq('GrafanaIntegration') }
- it { expect(described_class).to require_graphql_authorizations(:admin_operations) }
+ specify { expect(described_class).to require_graphql_authorizations(:admin_operations) }
- it { expect(described_class).to have_graphql_fields(*expected_fields) }
+ specify { expect(described_class).to have_graphql_fields(*expected_fields) }
end
diff --git a/spec/graphql/types/group_type_spec.rb b/spec/graphql/types/group_type_spec.rb
index 532f1a4b53d..a834a9038db 100644
--- a/spec/graphql/types/group_type_spec.rb
+++ b/spec/graphql/types/group_type_spec.rb
@@ -3,11 +3,11 @@
require 'spec_helper'
describe GitlabSchema.types['Group'] do
- it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Group) }
+ specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Group) }
- it { expect(described_class.graphql_name).to eq('Group') }
+ specify { expect(described_class.graphql_name).to eq('Group') }
- it { expect(described_class).to require_graphql_authorizations(:read_group) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_group) }
it 'has the expected fields' do
expected_fields = %w[
diff --git a/spec/graphql/types/issuable_sort_enum_spec.rb b/spec/graphql/types/issuable_sort_enum_spec.rb
new file mode 100644
index 00000000000..35c42d8194c
--- /dev/null
+++ b/spec/graphql/types/issuable_sort_enum_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Types::IssuableSortEnum do
+ specify { expect(described_class.graphql_name).to eq('IssuableSort') }
+
+ it 'exposes all the existing issuable sort values' do
+ expect(described_class.values.keys).to include(
+ *%w[PRIORITY_ASC PRIORITY_DESC
+ LABEL_PRIORITY_ASC LABEL_PRIORITY_DESC
+ MILESTONE_DUE_ASC MILESTONE_DUE_DESC]
+ )
+ end
+end
diff --git a/spec/graphql/types/issuable_state_enum_spec.rb b/spec/graphql/types/issuable_state_enum_spec.rb
index 65a80fa4176..f974ed5f5fb 100644
--- a/spec/graphql/types/issuable_state_enum_spec.rb
+++ b/spec/graphql/types/issuable_state_enum_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe GitlabSchema.types['IssuableState'] do
- it { expect(described_class.graphql_name).to eq('IssuableState') }
+ specify { expect(described_class.graphql_name).to eq('IssuableState') }
it_behaves_like 'issuable state'
end
diff --git a/spec/graphql/types/issue_sort_enum_spec.rb b/spec/graphql/types/issue_sort_enum_spec.rb
index 1b6aa6d6069..c496b897cdb 100644
--- a/spec/graphql/types/issue_sort_enum_spec.rb
+++ b/spec/graphql/types/issue_sort_enum_spec.rb
@@ -3,11 +3,13 @@
require 'spec_helper'
describe GitlabSchema.types['IssueSort'] do
- it { expect(described_class.graphql_name).to eq('IssueSort') }
+ specify { expect(described_class.graphql_name).to eq('IssueSort') }
it_behaves_like 'common sort values'
it 'exposes all the existing issue sort values' do
- expect(described_class.values.keys).to include(*%w[DUE_DATE_ASC DUE_DATE_DESC RELATIVE_POSITION_ASC])
+ expect(described_class.values.keys).to include(
+ *%w[DUE_DATE_ASC DUE_DATE_DESC RELATIVE_POSITION_ASC]
+ )
end
end
diff --git a/spec/graphql/types/issue_state_enum_spec.rb b/spec/graphql/types/issue_state_enum_spec.rb
index de19e6fc505..a18c5f5d317 100644
--- a/spec/graphql/types/issue_state_enum_spec.rb
+++ b/spec/graphql/types/issue_state_enum_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe GitlabSchema.types['IssueState'] do
- it { expect(described_class.graphql_name).to eq('IssueState') }
+ specify { expect(described_class.graphql_name).to eq('IssueState') }
it_behaves_like 'issuable state'
end
diff --git a/spec/graphql/types/issue_type_spec.rb b/spec/graphql/types/issue_type_spec.rb
index ebe48c17c11..a8f7edcfe8e 100644
--- a/spec/graphql/types/issue_type_spec.rb
+++ b/spec/graphql/types/issue_type_spec.rb
@@ -3,18 +3,19 @@
require 'spec_helper'
describe GitlabSchema.types['Issue'] do
- it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Issue) }
+ specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Issue) }
- it { expect(described_class.graphql_name).to eq('Issue') }
+ specify { expect(described_class.graphql_name).to eq('Issue') }
- it { expect(described_class).to require_graphql_authorizations(:read_issue) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_issue) }
- it { expect(described_class.interfaces).to include(Types::Notes::NoteableType) }
+ specify { expect(described_class.interfaces).to include(Types::Notes::NoteableType) }
it 'has specific fields' do
fields = %i[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]
+ subscribed time_estimate total_time_spent closed_at created_at updated_at task_completion_status
+ designs design_collection]
fields.each do |field_name|
expect(described_class).to have_graphql_field(field_name)
diff --git a/spec/graphql/types/jira_import_type_spec.rb b/spec/graphql/types/jira_import_type_spec.rb
index 8448a120682..ac1aa672e30 100644
--- a/spec/graphql/types/jira_import_type_spec.rb
+++ b/spec/graphql/types/jira_import_type_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
describe GitlabSchema.types['JiraImport'] do
- it { expect(described_class.graphql_name).to eq('JiraImport') }
+ specify { expect(described_class.graphql_name).to eq('JiraImport') }
it 'has the expected fields' do
- expect(described_class).to have_graphql_fields(:jira_project_key, :scheduled_at, :scheduled_by)
+ expect(described_class).to have_graphql_fields(:jira_project_key, :createdAt, :scheduled_at, :scheduled_by)
end
end
diff --git a/spec/graphql/types/label_type_spec.rb b/spec/graphql/types/label_type_spec.rb
index 71b86d9b528..026c63906ef 100644
--- a/spec/graphql/types/label_type_spec.rb
+++ b/spec/graphql/types/label_type_spec.rb
@@ -8,5 +8,5 @@ describe GitlabSchema.types['Label'] do
expect(described_class).to have_graphql_fields(*expected_fields)
end
- it { expect(described_class).to require_graphql_authorizations(:read_label) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_label) }
end
diff --git a/spec/graphql/types/merge_request_state_enum_spec.rb b/spec/graphql/types/merge_request_state_enum_spec.rb
index 626e33b18d3..2abc7b298b1 100644
--- a/spec/graphql/types/merge_request_state_enum_spec.rb
+++ b/spec/graphql/types/merge_request_state_enum_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe GitlabSchema.types['MergeRequestState'] do
- it { expect(described_class.graphql_name).to eq('MergeRequestState') }
+ specify { expect(described_class.graphql_name).to eq('MergeRequestState') }
it_behaves_like 'issuable state'
diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb
index 0c83ebd3de9..e7ab2100084 100644
--- a/spec/graphql/types/merge_request_type_spec.rb
+++ b/spec/graphql/types/merge_request_type_spec.rb
@@ -3,11 +3,11 @@
require 'spec_helper'
describe GitlabSchema.types['MergeRequest'] do
- it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) }
+ specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) }
- it { expect(described_class).to require_graphql_authorizations(:read_merge_request) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_merge_request) }
- it { expect(described_class.interfaces).to include(Types::Notes::NoteableType) }
+ specify { expect(described_class.interfaces).to include(Types::Notes::NoteableType) }
it 'has the expected fields' do
expected_fields = %w[
diff --git a/spec/graphql/types/metadata_type_spec.rb b/spec/graphql/types/metadata_type_spec.rb
index c8270a8c2f5..75369ec9c3c 100644
--- a/spec/graphql/types/metadata_type_spec.rb
+++ b/spec/graphql/types/metadata_type_spec.rb
@@ -3,6 +3,6 @@
require 'spec_helper'
describe GitlabSchema.types['Metadata'] do
- it { expect(described_class.graphql_name).to eq('Metadata') }
- it { expect(described_class).to require_graphql_authorizations(:read_instance_metadata) }
+ specify { expect(described_class.graphql_name).to eq('Metadata') }
+ specify { expect(described_class).to require_graphql_authorizations(:read_instance_metadata) }
end
diff --git a/spec/graphql/types/metrics/dashboard_type_spec.rb b/spec/graphql/types/metrics/dashboard_type_spec.rb
index 76f2b4b8935..81219c596a7 100644
--- a/spec/graphql/types/metrics/dashboard_type_spec.rb
+++ b/spec/graphql/types/metrics/dashboard_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe GitlabSchema.types['MetricsDashboard'] do
- it { expect(described_class.graphql_name).to eq('MetricsDashboard') }
+ specify { expect(described_class.graphql_name).to eq('MetricsDashboard') }
it 'has the expected fields' do
expected_fields = %w[
diff --git a/spec/graphql/types/metrics/dashboards/annotation_type_spec.rb b/spec/graphql/types/metrics/dashboards/annotation_type_spec.rb
index 2956a2512eb..dbb8b04dbd7 100644
--- a/spec/graphql/types/metrics/dashboards/annotation_type_spec.rb
+++ b/spec/graphql/types/metrics/dashboards/annotation_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe GitlabSchema.types['MetricsDashboardAnnotation'] do
- it { expect(described_class.graphql_name).to eq('MetricsDashboardAnnotation') }
+ specify { expect(described_class.graphql_name).to eq('MetricsDashboardAnnotation') }
it 'has the expected fields' do
expected_fields = %w[
@@ -13,5 +13,5 @@ describe GitlabSchema.types['MetricsDashboardAnnotation'] do
expect(described_class).to have_graphql_fields(*expected_fields)
end
- it { expect(described_class).to require_graphql_authorizations(:read_metrics_dashboard_annotation) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_metrics_dashboard_annotation) }
end
diff --git a/spec/graphql/types/milestone_type_spec.rb b/spec/graphql/types/milestone_type_spec.rb
index f7ee79eae9f..4c3d9f50a64 100644
--- a/spec/graphql/types/milestone_type_spec.rb
+++ b/spec/graphql/types/milestone_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe GitlabSchema.types['Milestone'] do
- it { expect(described_class.graphql_name).to eq('Milestone') }
+ specify { expect(described_class.graphql_name).to eq('Milestone') }
- it { expect(described_class).to require_graphql_authorizations(:read_milestone) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_milestone) }
end
diff --git a/spec/graphql/types/namespace_type_spec.rb b/spec/graphql/types/namespace_type_spec.rb
index 6c2ba70cf4c..741698021e7 100644
--- a/spec/graphql/types/namespace_type_spec.rb
+++ b/spec/graphql/types/namespace_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe GitlabSchema.types['Namespace'] do
- it { expect(described_class.graphql_name).to eq('Namespace') }
+ specify { expect(described_class.graphql_name).to eq('Namespace') }
it 'has the expected fields' do
expected_fields = %w[
@@ -14,5 +14,5 @@ describe GitlabSchema.types['Namespace'] do
expect(described_class).to have_graphql_fields(*expected_fields)
end
- it { expect(described_class).to require_graphql_authorizations(:read_namespace) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_namespace) }
end
diff --git a/spec/graphql/types/notes/discussion_type_spec.rb b/spec/graphql/types/notes/discussion_type_spec.rb
index 804785ba67d..44774594d17 100644
--- a/spec/graphql/types/notes/discussion_type_spec.rb
+++ b/spec/graphql/types/notes/discussion_type_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
describe GitlabSchema.types['Discussion'] do
- it { expect(described_class).to have_graphql_fields(:id, :created_at, :notes, :reply_id) }
+ specify { expect(described_class).to have_graphql_fields(:id, :created_at, :notes, :reply_id) }
- it { expect(described_class).to require_graphql_authorizations(:read_note) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_note) }
end
diff --git a/spec/graphql/types/notes/note_type_spec.rb b/spec/graphql/types/notes/note_type_spec.rb
index 8cf84cd8dfd..019f742ee77 100644
--- a/spec/graphql/types/notes/note_type_spec.rb
+++ b/spec/graphql/types/notes/note_type_spec.rb
@@ -10,6 +10,6 @@ describe GitlabSchema.types['Note'] do
expect(described_class).to have_graphql_fields(*expected_fields)
end
- it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Note) }
- it { expect(described_class).to require_graphql_authorizations(:read_note) }
+ specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Note) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_note) }
end
diff --git a/spec/graphql/types/notes/noteable_type_spec.rb b/spec/graphql/types/notes/noteable_type_spec.rb
index a4259e160e0..4a81f45bd4e 100644
--- a/spec/graphql/types/notes/noteable_type_spec.rb
+++ b/spec/graphql/types/notes/noteable_type_spec.rb
@@ -2,12 +2,13 @@
require 'spec_helper'
describe Types::Notes::NoteableType do
- it { expect(described_class).to have_graphql_fields(:notes, :discussions) }
+ specify { expect(described_class).to have_graphql_fields(:notes, :discussions) }
describe ".resolve_type" do
it 'knows the correct type for objects' do
expect(described_class.resolve_type(build(:issue), {})).to eq(Types::IssueType)
expect(described_class.resolve_type(build(:merge_request), {})).to eq(Types::MergeRequestType)
+ expect(described_class.resolve_type(build(:design), {})).to eq(Types::DesignManagement::DesignType)
end
end
end
diff --git a/spec/graphql/types/permission_types/issue_spec.rb b/spec/graphql/types/permission_types/issue_spec.rb
index a94bc6b780e..a7a3dd00f11 100644
--- a/spec/graphql/types/permission_types/issue_spec.rb
+++ b/spec/graphql/types/permission_types/issue_spec.rb
@@ -5,8 +5,9 @@ require 'spec_helper'
describe Types::PermissionTypes::Issue do
it do
expected_permissions = [
- :read_issue, :admin_issue, :update_issue,
- :create_note, :reopen_issue
+ :read_issue, :admin_issue, :update_issue, :reopen_issue,
+ :read_design, :create_design, :destroy_design,
+ :create_note
]
expected_permissions.each do |permission|
diff --git a/spec/graphql/types/permission_types/merge_request_type_spec.rb b/spec/graphql/types/permission_types/merge_request_type_spec.rb
index 572b4ac42d0..7e9752cdc46 100644
--- a/spec/graphql/types/permission_types/merge_request_type_spec.rb
+++ b/spec/graphql/types/permission_types/merge_request_type_spec.rb
@@ -3,5 +3,5 @@
require 'spec_helper'
describe Types::MergeRequestType do
- it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) }
+ specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) }
end
diff --git a/spec/graphql/types/permission_types/project_spec.rb b/spec/graphql/types/permission_types/project_spec.rb
index 56c4c2de4df..2789464d29c 100644
--- a/spec/graphql/types/permission_types/project_spec.rb
+++ b/spec/graphql/types/permission_types/project_spec.rb
@@ -13,7 +13,7 @@ describe Types::PermissionTypes::Project do
:create_merge_request_from, :create_wiki, :push_code, :create_deployment, :push_to_delete_protected_branch,
:admin_wiki, :admin_project, :update_pages, :admin_remote_mirror, :create_label,
:update_wiki, :destroy_wiki, :create_pages, :destroy_pages, :read_pages_content,
- :read_merge_request
+ :read_merge_request, :read_design, :create_design, :destroy_design
]
expected_permissions.each do |permission|
diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb
index 6ea852190c9..6368f743720 100644
--- a/spec/graphql/types/project_type_spec.rb
+++ b/spec/graphql/types/project_type_spec.rb
@@ -3,11 +3,11 @@
require 'spec_helper'
describe GitlabSchema.types['Project'] do
- it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Project) }
+ specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Project) }
- it { expect(described_class.graphql_name).to eq('Project') }
+ specify { expect(described_class.graphql_name).to eq('Project') }
- it { expect(described_class).to require_graphql_authorizations(:read_project) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_project) }
it 'has the expected fields' do
expected_fields = %w[
@@ -24,7 +24,8 @@ describe GitlabSchema.types['Project'] do
namespace group statistics repository merge_requests merge_request issues
issue pipelines removeSourceBranchAfterMerge sentryDetailedError snippets
grafanaIntegration autocloseReferencedIssues suggestion_commit_message environments
- boards jira_import_status jira_imports services
+ boards jira_import_status jira_imports services releases release
+ alert_management_alerts alert_management_alert alert_management_alert_status_counts
]
expect(described_class).to include_graphql_fields(*expected_fields)
@@ -96,4 +97,18 @@ describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_type(Types::Projects::ServiceType.connection_type) }
end
+
+ describe 'releases field' do
+ subject { described_class.fields['release'] }
+
+ it { is_expected.to have_graphql_type(Types::ReleaseType) }
+ it { is_expected.to have_graphql_resolver(Resolvers::ReleaseResolver) }
+ end
+
+ describe 'release field' do
+ subject { described_class.fields['releases'] }
+
+ it { is_expected.to have_graphql_type(Types::ReleaseType.connection_type) }
+ it { is_expected.to have_graphql_resolver(Resolvers::ReleasesResolver) }
+ end
end
diff --git a/spec/graphql/types/projects/base_service_type_spec.rb b/spec/graphql/types/projects/base_service_type_spec.rb
index bda6022bf79..4fcb9fe1a73 100644
--- a/spec/graphql/types/projects/base_service_type_spec.rb
+++ b/spec/graphql/types/projects/base_service_type_spec.rb
@@ -3,11 +3,11 @@
require 'spec_helper'
describe GitlabSchema.types['BaseService'] do
- it { expect(described_class.graphql_name).to eq('BaseService') }
+ specify { expect(described_class.graphql_name).to eq('BaseService') }
it 'has basic expected fields' do
expect(described_class).to have_graphql_fields(:type, :active)
end
- it { expect(described_class).to require_graphql_authorizations(:admin_project) }
+ specify { expect(described_class).to require_graphql_authorizations(:admin_project) }
end
diff --git a/spec/graphql/types/projects/jira_service_type_spec.rb b/spec/graphql/types/projects/jira_service_type_spec.rb
index 7f8fa6538e9..91d7e4586cb 100644
--- a/spec/graphql/types/projects/jira_service_type_spec.rb
+++ b/spec/graphql/types/projects/jira_service_type_spec.rb
@@ -3,11 +3,11 @@
require 'spec_helper'
describe GitlabSchema.types['JiraService'] do
- it { expect(described_class.graphql_name).to eq('JiraService') }
+ specify { expect(described_class.graphql_name).to eq('JiraService') }
it 'has basic expected fields' do
expect(described_class).to have_graphql_fields(:type, :active)
end
- it { expect(described_class).to require_graphql_authorizations(:admin_project) }
+ specify { expect(described_class).to require_graphql_authorizations(:admin_project) }
end
diff --git a/spec/graphql/types/projects/service_type_spec.rb b/spec/graphql/types/projects/service_type_spec.rb
index ad30a4008bc..f6758d17d18 100644
--- a/spec/graphql/types/projects/service_type_spec.rb
+++ b/spec/graphql/types/projects/service_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe Types::Projects::ServiceType do
- it { expect(described_class).to have_graphql_fields(:type, :active) }
+ specify { expect(described_class).to have_graphql_fields(:type, :active) }
describe ".resolve_type" do
it 'resolves the corresponding type for objects' do
diff --git a/spec/graphql/types/projects/services_enum_spec.rb b/spec/graphql/types/projects/services_enum_spec.rb
index aac4aae4f69..91e398e8d81 100644
--- a/spec/graphql/types/projects/services_enum_spec.rb
+++ b/spec/graphql/types/projects/services_enum_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe GitlabSchema.types['ServiceType'] do
- it { expect(described_class.graphql_name).to eq('ServiceType') }
+ specify { expect(described_class.graphql_name).to eq('ServiceType') }
it 'exposes all the existing project services' do
expect(described_class.values.keys).to match_array(available_services_enum)
diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb
index ab210f2e918..1f269a80d00 100644
--- a/spec/graphql/types/query_type_spec.rb
+++ b/spec/graphql/types/query_type_spec.rb
@@ -8,7 +8,7 @@ describe GitlabSchema.types['Query'] do
end
it 'has the expected fields' do
- expected_fields = %i[project namespace group echo metadata current_user snippets]
+ expected_fields = %i[project namespace group echo metadata current_user snippets design_management]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
end
diff --git a/spec/graphql/types/release_type_spec.rb b/spec/graphql/types/release_type_spec.rb
new file mode 100644
index 00000000000..d22a0b4f0fa
--- /dev/null
+++ b/spec/graphql/types/release_type_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GitlabSchema.types['Release'] do
+ it { expect(described_class).to require_graphql_authorizations(:read_release) }
+
+ it 'has the expected fields' do
+ expected_fields = %w[
+ tag_name tag_path
+ description description_html
+ name milestones author commit
+ created_at released_at
+ ]
+
+ expect(described_class).to include_graphql_fields(*expected_fields)
+ end
+
+ describe 'milestones field' do
+ subject { described_class.fields['milestones'] }
+
+ it { is_expected.to have_graphql_type(Types::MilestoneType.connection_type) }
+ end
+
+ describe 'author field' do
+ subject { described_class.fields['author'] }
+
+ it { is_expected.to have_graphql_type(Types::UserType) }
+ end
+
+ describe 'commit field' do
+ subject { described_class.fields['commit'] }
+
+ it { is_expected.to have_graphql_type(Types::CommitType) }
+ it { is_expected.to require_graphql_authorizations(:reporter_access) }
+ end
+end
diff --git a/spec/graphql/types/repository_type_spec.rb b/spec/graphql/types/repository_type_spec.rb
index f746e75b574..fb52839c712 100644
--- a/spec/graphql/types/repository_type_spec.rb
+++ b/spec/graphql/types/repository_type_spec.rb
@@ -3,11 +3,11 @@
require 'spec_helper'
describe GitlabSchema.types['Repository'] do
- it { expect(described_class.graphql_name).to eq('Repository') }
+ specify { expect(described_class.graphql_name).to eq('Repository') }
- it { expect(described_class).to require_graphql_authorizations(:download_code) }
+ specify { expect(described_class).to require_graphql_authorizations(:download_code) }
- it { expect(described_class).to have_graphql_field(:root_ref) }
+ specify { expect(described_class).to have_graphql_field(:root_ref) }
- it { expect(described_class).to have_graphql_field(:tree) }
+ specify { expect(described_class).to have_graphql_field(:tree) }
end
diff --git a/spec/graphql/types/root_storage_statistics_type_spec.rb b/spec/graphql/types/root_storage_statistics_type_spec.rb
index b796b974b82..ebaa5a18623 100644
--- a/spec/graphql/types/root_storage_statistics_type_spec.rb
+++ b/spec/graphql/types/root_storage_statistics_type_spec.rb
@@ -3,12 +3,12 @@
require 'spec_helper'
describe GitlabSchema.types['RootStorageStatistics'] do
- it { expect(described_class.graphql_name).to eq('RootStorageStatistics') }
+ specify { expect(described_class.graphql_name).to eq('RootStorageStatistics') }
it 'has all the required fields' do
expect(described_class).to have_graphql_fields(:storage_size, :repository_size, :lfs_objects_size,
:build_artifacts_size, :packages_size, :wiki_size)
end
- it { expect(described_class).to require_graphql_authorizations(:read_statistics) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_statistics) }
end
diff --git a/spec/graphql/types/snippet_type_spec.rb b/spec/graphql/types/snippet_type_spec.rb
index 6e580711fda..adc13d4d651 100644
--- a/spec/graphql/types/snippet_type_spec.rb
+++ b/spec/graphql/types/snippet_type_spec.rb
@@ -17,7 +17,7 @@ describe GitlabSchema.types['Snippet'] do
end
describe 'authorizations' do
- it { expect(described_class).to require_graphql_authorizations(:read_snippet) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_snippet) }
end
shared_examples 'response without repository URLs' do
@@ -35,14 +35,6 @@ describe GitlabSchema.types['Snippet'] do
expect(response['sshUrlToRepo']).to eq(snippet.ssh_url_to_repo)
expect(response['httpUrlToRepo']).to eq(snippet.http_url_to_repo)
end
-
- context 'when version_snippets feature is disabled' do
- before do
- stub_feature_flags(version_snippets: false)
- end
-
- it_behaves_like 'response without repository URLs'
- end
end
end
diff --git a/spec/graphql/types/snippets/blob_type_spec.rb b/spec/graphql/types/snippets/blob_type_spec.rb
index da36ab80f44..fb8c6896732 100644
--- a/spec/graphql/types/snippets/blob_type_spec.rb
+++ b/spec/graphql/types/snippets/blob_type_spec.rb
@@ -6,8 +6,22 @@ describe GitlabSchema.types['SnippetBlob'] do
it 'has the correct fields' do
expected_fields = [:rich_data, :plain_data,
:raw_path, :size, :binary, :name, :path,
- :simple_viewer, :rich_viewer, :mode]
+ :simple_viewer, :rich_viewer, :mode, :external_storage,
+ :rendered_as_text]
expect(described_class).to have_graphql_fields(*expected_fields)
end
+
+ specify { expect(described_class.fields['richData'].type).not_to be_non_null }
+ specify { expect(described_class.fields['plainData'].type).not_to be_non_null }
+ specify { expect(described_class.fields['rawPath'].type).to be_non_null }
+ specify { expect(described_class.fields['size'].type).to be_non_null }
+ specify { expect(described_class.fields['binary'].type).to be_non_null }
+ specify { expect(described_class.fields['name'].type).not_to be_non_null }
+ specify { expect(described_class.fields['path'].type).not_to be_non_null }
+ specify { expect(described_class.fields['simpleViewer'].type).to be_non_null }
+ specify { expect(described_class.fields['richViewer'].type).not_to be_non_null }
+ specify { expect(described_class.fields['mode'].type).not_to be_non_null }
+ specify { expect(described_class.fields['externalStorage'].type).not_to be_non_null }
+ specify { expect(described_class.fields['renderedAsText'].type).to be_non_null }
end
diff --git a/spec/graphql/types/snippets/blob_viewer_type_spec.rb b/spec/graphql/types/snippets/blob_viewer_type_spec.rb
index a51d09813ab..841e22451db 100644
--- a/spec/graphql/types/snippets/blob_viewer_type_spec.rb
+++ b/spec/graphql/types/snippets/blob_viewer_type_spec.rb
@@ -3,10 +3,91 @@
require 'spec_helper'
describe GitlabSchema.types['SnippetBlobViewer'] do
+ let_it_be(:snippet) { create(:personal_snippet, :repository) }
+ let_it_be(:blob) { snippet.repository.blob_at('HEAD', 'files/images/6049019_460s.jpg') }
+
it 'has the correct fields' do
expected_fields = [:type, :load_async, :too_large, :collapsed,
:render_error, :file_type, :loading_partial_name]
expect(described_class).to have_graphql_fields(*expected_fields)
end
+
+ it { expect(described_class.fields['type'].type).to be_non_null }
+ it { expect(described_class.fields['loadAsync'].type).to be_non_null }
+ it { expect(described_class.fields['collapsed'].type).to be_non_null }
+ it { expect(described_class.fields['tooLarge'].type).to be_non_null }
+ it { expect(described_class.fields['renderError'].type).not_to be_non_null }
+ it { expect(described_class.fields['fileType'].type).to be_non_null }
+ it { expect(described_class.fields['loadingPartialName'].type).to be_non_null }
+
+ shared_examples 'nil field converted to false' do
+ subject { GitlabSchema.execute(query, context: { current_user: snippet.author }).as_json }
+
+ before do
+ allow_next_instance_of(SnippetPresenter) do |instance|
+ allow(instance).to receive(:blob).and_return(blob)
+ end
+ end
+
+ it 'returns false' do
+ snippet_blob = subject.dig('data', 'snippets', 'edges')[0].dig('node', 'blob')
+
+ expect(snippet_blob['path']).to eq blob.path
+ expect(blob_attribute).to be_nil
+ expect(snippet_blob['simpleViewer'][attribute]).to eq false
+ end
+ end
+
+ describe 'collapsed' do
+ it_behaves_like 'nil field converted to false' do
+ let(:query) do
+ %(
+ query {
+ snippets(ids:"#{snippet.to_global_id}"){
+ edges {
+ node {
+ blob {
+ path
+ simpleViewer {
+ collapsed
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+ end
+
+ let(:attribute) { 'collapsed' }
+ let(:blob_attribute) { blob.simple_viewer.collapsed? }
+ end
+ end
+
+ describe 'tooLarge' do
+ it_behaves_like 'nil field converted to false' do
+ let(:query) do
+ %(
+ query {
+ snippets(ids:"#{snippet.to_global_id}"){
+ edges {
+ node {
+ blob {
+ path
+ simpleViewer {
+ tooLarge
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+ end
+
+ let(:attribute) { 'tooLarge' }
+ let(:blob_attribute) { blob.simple_viewer.too_large? }
+ end
+ end
end
diff --git a/spec/graphql/types/time_type_spec.rb b/spec/graphql/types/time_type_spec.rb
index 88a535ed3bb..3c6e191e2fb 100644
--- a/spec/graphql/types/time_type_spec.rb
+++ b/spec/graphql/types/time_type_spec.rb
@@ -6,7 +6,7 @@ describe GitlabSchema.types['Time'] do
let(:iso) { "2018-06-04T15:23:50+02:00" }
let(:time) { Time.parse(iso) }
- it { expect(described_class.graphql_name).to eq('Time') }
+ specify { expect(described_class.graphql_name).to eq('Time') }
it 'coerces Time object into ISO 8601' do
expect(described_class.coerce_isolated_result(time)).to eq(iso)
diff --git a/spec/graphql/types/todo_type_spec.rb b/spec/graphql/types/todo_type_spec.rb
index 59118259d09..87a5405f0e2 100644
--- a/spec/graphql/types/todo_type_spec.rb
+++ b/spec/graphql/types/todo_type_spec.rb
@@ -9,5 +9,5 @@ describe GitlabSchema.types['Todo'] do
expect(described_class).to have_graphql_fields(*expected_fields)
end
- it { expect(described_class).to require_graphql_authorizations(:read_todo) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_todo) }
end
diff --git a/spec/graphql/types/tree/blob_type_spec.rb b/spec/graphql/types/tree/blob_type_spec.rb
index 516c862b9c6..547a03b5edf 100644
--- a/spec/graphql/types/tree/blob_type_spec.rb
+++ b/spec/graphql/types/tree/blob_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe Types::Tree::BlobType do
- it { expect(described_class.graphql_name).to eq('Blob') }
+ specify { expect(described_class.graphql_name).to eq('Blob') }
- it { expect(described_class).to have_graphql_fields(:id, :sha, :name, :type, :path, :flat_path, :web_url, :lfs_oid) }
+ specify { expect(described_class).to have_graphql_fields(:id, :sha, :name, :type, :path, :flat_path, :web_url, :lfs_oid) }
end
diff --git a/spec/graphql/types/tree/submodule_type_spec.rb b/spec/graphql/types/tree/submodule_type_spec.rb
index 81f7ad825a1..b5cfe8eb812 100644
--- a/spec/graphql/types/tree/submodule_type_spec.rb
+++ b/spec/graphql/types/tree/submodule_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe Types::Tree::SubmoduleType do
- it { expect(described_class.graphql_name).to eq('Submodule') }
+ specify { expect(described_class.graphql_name).to eq('Submodule') }
- it { expect(described_class).to have_graphql_fields(:id, :sha, :name, :type, :path, :flat_path, :web_url, :tree_url) }
+ specify { expect(described_class).to have_graphql_fields(:id, :sha, :name, :type, :path, :flat_path, :web_url, :tree_url) }
end
diff --git a/spec/graphql/types/tree/tree_entry_type_spec.rb b/spec/graphql/types/tree/tree_entry_type_spec.rb
index 228a4be0949..14826d06645 100644
--- a/spec/graphql/types/tree/tree_entry_type_spec.rb
+++ b/spec/graphql/types/tree/tree_entry_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe Types::Tree::TreeEntryType do
- it { expect(described_class.graphql_name).to eq('TreeEntry') }
+ specify { expect(described_class.graphql_name).to eq('TreeEntry') }
- it { expect(described_class).to have_graphql_fields(:id, :sha, :name, :type, :path, :flat_path, :web_url) }
+ specify { expect(described_class).to have_graphql_fields(:id, :sha, :name, :type, :path, :flat_path, :web_url) }
end
diff --git a/spec/graphql/types/tree/tree_type_spec.rb b/spec/graphql/types/tree/tree_type_spec.rb
index 23779d75600..93faebd3602 100644
--- a/spec/graphql/types/tree/tree_type_spec.rb
+++ b/spec/graphql/types/tree/tree_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe Types::Tree::TreeType do
- it { expect(described_class.graphql_name).to eq('Tree') }
+ specify { expect(described_class.graphql_name).to eq('Tree') }
- it { expect(described_class).to have_graphql_fields(:trees, :submodules, :blobs, :last_commit) }
+ specify { expect(described_class).to have_graphql_fields(:trees, :submodules, :blobs, :last_commit) }
end
diff --git a/spec/graphql/types/tree/type_enum_spec.rb b/spec/graphql/types/tree/type_enum_spec.rb
index 4caf9e1c457..dcacd6073f9 100644
--- a/spec/graphql/types/tree/type_enum_spec.rb
+++ b/spec/graphql/types/tree/type_enum_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe Types::Tree::TypeEnum do
- it { expect(described_class.graphql_name).to eq('EntryType') }
+ specify { expect(described_class.graphql_name).to eq('EntryType') }
it 'exposes all tree entry types' do
expect(described_class.values.keys).to include(*%w[tree blob commit])
diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb
index 8c76ce43e95..cf1e91afb80 100644
--- a/spec/graphql/types/user_type_spec.rb
+++ b/spec/graphql/types/user_type_spec.rb
@@ -3,13 +3,13 @@
require 'spec_helper'
describe GitlabSchema.types['User'] do
- it { expect(described_class.graphql_name).to eq('User') }
+ specify { expect(described_class.graphql_name).to eq('User') }
- it { expect(described_class).to require_graphql_authorizations(:read_user) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_user) }
it 'has the expected fields' do
expected_fields = %w[
- user_permissions snippets name username avatarUrl webUrl todos
+ id user_permissions snippets name username avatarUrl webUrl todos state
]
expect(described_class).to have_graphql_fields(*expected_fields)
diff --git a/spec/haml_lint/linter/no_plain_nodes_spec.rb b/spec/haml_lint/linter/no_plain_nodes_spec.rb
index 08deb5a4e9e..dc647467db6 100644
--- a/spec/haml_lint/linter/no_plain_nodes_spec.rb
+++ b/spec/haml_lint/linter/no_plain_nodes_spec.rb
@@ -53,4 +53,42 @@ describe HamlLint::Linter::NoPlainNodes do
it { is_expected.to report_lint count: 3 }
end
+
+ context 'does not report when a html entity' do
+ let(:haml) { '%tag &nbsp;' }
+
+ it { is_expected.not_to report_lint }
+ end
+
+ context 'does report when something that looks like a html entity' do
+ let(:haml) { '%tag &some text;' }
+
+ it { is_expected.to report_lint }
+ end
+
+ context 'does not report multiline when one or more html entities' do
+ %w(&nbsp;&gt; &#x000A9; &#187;).each do |elem|
+ let(:haml) { <<-HAML }
+ %tag
+ #{elem}
+ HAML
+
+ it elem do
+ is_expected.not_to report_lint
+ end
+ end
+ end
+
+ context 'does report multiline when one or more html entities amidst plain text' do
+ %w(&nbsp;Test Test&gt; &#x000A9;Hello &nbsp;Hello&#187;).each do |elem|
+ let(:haml) { <<-HAML }
+ %tag
+ #{elem}
+ HAML
+
+ it elem do
+ is_expected.to report_lint
+ end
+ end
+ end
end
diff --git a/spec/helpers/access_tokens_helper_spec.rb b/spec/helpers/access_tokens_helper_spec.rb
new file mode 100644
index 00000000000..1d246d3f236
--- /dev/null
+++ b/spec/helpers/access_tokens_helper_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+describe AccessTokensHelper do
+ describe "#scope_description" do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:prefix, :description_location) do
+ :personal_access_token | [:doorkeeper, :scope_desc]
+ :project_access_token | [:doorkeeper, :project_access_token_scope_desc]
+ end
+
+ with_them do
+ it { expect(helper.scope_description(prefix)).to eq(description_location) }
+ end
+ end
+end
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index a96046735c8..05231cc6d09 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -71,6 +71,28 @@ describe ApplicationHelper do
end
end
+ describe '#admin_section?' do
+ context 'when controller is under the admin namespace' do
+ before do
+ allow(helper).to receive(:controller).and_return(Admin::UsersController.new)
+ end
+
+ it 'returns true' do
+ expect(helper.admin_section?).to eq(true)
+ end
+ end
+
+ context 'when controller is not under the admin namespace' do
+ before do
+ allow(helper).to receive(:controller).and_return(UsersController.new)
+ end
+
+ it 'returns true' do
+ expect(helper.admin_section?).to eq(false)
+ end
+ end
+ end
+
describe 'simple_sanitize' do
let(:a_tag) { '<a href="#">Foo</a>' }
@@ -90,8 +112,11 @@ describe ApplicationHelper do
end
describe 'time_ago_with_tooltip' do
+ around do |example|
+ Time.use_zone('UTC') { example.run }
+ end
+
def element(*arguments)
- Time.zone = 'UTC'
@time = Time.zone.parse('2015-07-02 08:23')
element = helper.time_ago_with_tooltip(@time, *arguments)
diff --git a/spec/helpers/auth_helper_spec.rb b/spec/helpers/auth_helper_spec.rb
index 1764a2bbc3c..23f3449d9a7 100644
--- a/spec/helpers/auth_helper_spec.rb
+++ b/spec/helpers/auth_helper_spec.rb
@@ -184,4 +184,40 @@ describe AuthHelper do
end
end
end
+
+ describe '#allow_admin_mode_password_authentication_for_web?' do
+ let(:user) { create(:user) }
+
+ subject { helper.allow_admin_mode_password_authentication_for_web? }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ it { is_expected.to be(true) }
+
+ context 'when password authentication for web is disabled' do
+ before do
+ stub_application_setting(password_authentication_enabled_for_web: false)
+ end
+
+ it { is_expected.to be(false) }
+ end
+
+ context 'when current_user is an ldap user' do
+ before do
+ allow(user).to receive(:ldap_user?).and_return(true)
+ end
+
+ it { is_expected.to be(false) }
+ end
+
+ context 'when user got password automatically set' do
+ before do
+ user.update_attribute(:password_automatically_set, true)
+ end
+
+ it { is_expected.to be(false) }
+ end
+ end
end
diff --git a/spec/helpers/boards_helper_spec.rb b/spec/helpers/boards_helper_spec.rb
index f5e5285554c..cb9be9d5fb4 100644
--- a/spec/helpers/boards_helper_spec.rb
+++ b/spec/helpers/boards_helper_spec.rb
@@ -48,6 +48,10 @@ describe BoardsHelper do
it 'returns a board_lists_path as lists_endpoint' do
expect(helper.board_data[:lists_endpoint]).to eq(board_lists_path(board))
end
+
+ it 'returns board type as parent' do
+ expect(helper.board_data[:parent]).to eq('project')
+ end
end
describe '#current_board_json' do
diff --git a/spec/helpers/clusters_helper_spec.rb b/spec/helpers/clusters_helper_spec.rb
index eec62bbb990..d40ed2248ce 100644
--- a/spec/helpers/clusters_helper_spec.rb
+++ b/spec/helpers/clusters_helper_spec.rb
@@ -59,6 +59,32 @@ describe ClustersHelper do
end
end
+ describe '#provider_icon' do
+ it 'will return GCP logo with gcp argument' do
+ logo = helper.provider_icon('gcp')
+
+ expect(logo).to match(%r(img alt="Google GKE" data-src="|/illustrations/logos/google_gke|svg))
+ end
+
+ it 'will return AWS logo with aws argument' do
+ logo = helper.provider_icon('aws')
+
+ expect(logo).to match(%r(img alt="Amazon EKS" data-src="|/illustrations/logos/amazon_eks|svg))
+ end
+
+ it 'will return default logo with unknown provider' do
+ logo = helper.provider_icon('unknown')
+
+ expect(logo).to match(%r(img alt="Kubernetes Cluster" data-src="|/illustrations/logos/kubernetes|svg))
+ end
+
+ it 'will return default logo when provider is empty' do
+ logo = helper.provider_icon
+
+ expect(logo).to match(%r(img alt="Kubernetes Cluster" data-src="|/illustrations/logos/kubernetes|svg))
+ end
+ end
+
describe '#cluster_type_label' do
subject { helper.cluster_type_label(cluster_type) }
diff --git a/spec/helpers/commits_helper_spec.rb b/spec/helpers/commits_helper_spec.rb
index dd268c2411f..e036e97f745 100644
--- a/spec/helpers/commits_helper_spec.rb
+++ b/spec/helpers/commits_helper_spec.rb
@@ -82,4 +82,32 @@ describe CommitsHelper do
expect(helper.commit_to_html(commit, ref, project)).to include('<div class="commit-content')
end
end
+
+ describe 'commit_path' do
+ it 'returns a persisted merge request commit path' do
+ project = create(:project, :repository)
+ persisted_merge_request = create(:merge_request, source_project: project, target_project: project)
+ commit = project.repository.commit
+
+ expect(helper.commit_path(persisted_merge_request.project, commit, merge_request: persisted_merge_request))
+ .to eq(diffs_project_merge_request_path(project, persisted_merge_request, commit_id: commit.id))
+ end
+
+ it 'returns a non-persisted merge request commit path which commits still reside in the source project' do
+ source_project = create(:project, :repository)
+ target_project = create(:project, :repository)
+ non_persisted_merge_request = build(:merge_request, source_project: source_project, target_project: target_project)
+ commit = source_project.repository.commit
+
+ expect(helper.commit_path(non_persisted_merge_request.project, commit, merge_request: non_persisted_merge_request))
+ .to eq(project_commit_path(source_project, commit))
+ end
+
+ it 'returns a project commit path' do
+ project = create(:project, :repository)
+ commit = project.repository.commit
+
+ expect(helper.commit_path(project, commit)).to eq(project_commit_path(project, commit))
+ end
+ end
end
diff --git a/spec/helpers/environments_helper_spec.rb b/spec/helpers/environments_helper_spec.rb
index 152e9c84ec5..0756e0162a5 100644
--- a/spec/helpers/environments_helper_spec.rb
+++ b/spec/helpers/environments_helper_spec.rb
@@ -37,10 +37,26 @@ describe EnvironmentsHelper do
'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'
+ 'custom-metrics-available' => 'true',
+ 'alerts-endpoint' => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json),
+ 'prometheus-alerts-available' => 'true'
)
end
+ context 'without read_prometheus_alerts permission' do
+ before do
+ allow(helper).to receive(:can?)
+ .with(user, :read_prometheus_alerts, project)
+ .and_return(false)
+ end
+
+ it 'returns false' do
+ expect(metrics_data).to include(
+ 'prometheus-alerts-available' => 'false'
+ )
+ end
+ end
+
context 'with metrics_setting' do
before do
create(:project_metrics_setting, project: project, external_dashboard_url: 'http://gitlab.com')
diff --git a/spec/helpers/events_helper_spec.rb b/spec/helpers/events_helper_spec.rb
index ff99f76eb4d..12519390137 100644
--- a/spec/helpers/events_helper_spec.rb
+++ b/spec/helpers/events_helper_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
describe EventsHelper do
+ include Gitlab::Routing
+
describe '#event_commit_title' do
let(:message) { 'foo & bar ' + 'A' * 70 + '\n' + 'B' * 80 }
@@ -197,5 +199,17 @@ describe EventsHelper do
expect(subject).to eq("#{project_base_url}/-/merge_requests/#{event.note_target.iid}#note_#{event.target.id}")
end
+
+ context 'for design note events' do
+ let(:event) { create(:event, :for_design, project: project) }
+
+ it 'returns an appropriate URL' do
+ iid = event.note_target.issue.iid
+ filename = event.note_target.filename
+ note_id = event.target.id
+
+ expect(subject).to eq("#{project_base_url}/-/issues/#{iid}/designs/#{filename}#note_#{note_id}")
+ end
+ end
end
end
diff --git a/spec/helpers/export_helper_spec.rb b/spec/helpers/export_helper_spec.rb
new file mode 100644
index 00000000000..3fbda441b5d
--- /dev/null
+++ b/spec/helpers/export_helper_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ExportHelper do
+ describe '#project_export_descriptions' do
+ it 'includes design management' do
+ expect(project_export_descriptions).to include('Design Management files and data')
+ end
+ end
+end
diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb
index ac2f028f937..5be247c5b49 100644
--- a/spec/helpers/groups_helper_spec.rb
+++ b/spec/helpers/groups_helper_spec.rb
@@ -340,4 +340,31 @@ describe GroupsHelper do
end
end
end
+
+ describe '#can_update_default_branch_protection?' do
+ let(:current_user) { create(:user) }
+ let(:group) { create(:group) }
+
+ subject { helper.can_update_default_branch_protection?(group) }
+
+ before do
+ allow(helper).to receive(:current_user) { current_user }
+ end
+
+ context 'for users who can update default branch protection of the group' do
+ before do
+ allow(helper).to receive(:can?).with(current_user, :update_default_branch_protection, group) { true }
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'for users who cannot update default branch protection of the group' do
+ before do
+ allow(helper).to receive(:can?).with(current_user, :update_default_branch_protection, group) { false }
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
end
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 7eb5d2fc08c..38ad11846d2 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -303,46 +303,4 @@ describe IssuablesHelper do
end
end
end
-
- describe '#gitlab_team_member_badge' do
- let(:issue) { build(:issue, author: user) }
-
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
- context 'when `:gitlab_employee_badge` feature flag is disabled' do
- let(:user) { build(:user, email: 'test@gitlab.com') }
-
- before do
- stub_feature_flags(gitlab_employee_badge: false)
- end
-
- it 'returns nil' do
- expect(helper.gitlab_team_member_badge(issue.author)).to be_nil
- end
- end
-
- context 'when issue author is not a GitLab team member' do
- let(:user) { build(:user, email: 'test@example.com') }
-
- it 'returns nil' do
- expect(helper.gitlab_team_member_badge(issue.author)).to be_nil
- end
- end
-
- context 'when issue author is a GitLab team member' do
- let(:user) { build(:user, email: 'test@gitlab.com') }
-
- it 'returns span with svg icon' do
- expect(helper.gitlab_team_member_badge(issue.author)).to have_selector('span > svg')
- end
-
- context 'when `css_class` parameter is passed' do
- it 'adds CSS classes' do
- expect(helper.gitlab_team_member_badge(issue.author, css_class: 'foo bar baz')).to have_selector('span.foo.bar.baz')
- end
- end
- end
- end
end
diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb
index 33347f20de8..b2df543d651 100644
--- a/spec/helpers/markup_helper_spec.rb
+++ b/spec/helpers/markup_helper_spec.rb
@@ -95,12 +95,13 @@ describe MarkupHelper do
context 'when text contains a relative link to an image in the repository' do
let(:image_file) { "logo-white.png" }
let(:text_with_relative_path) { "![](./#{image_file})\n" }
- let(:generated_html) { helper.markdown(text_with_relative_path, requested_path: requested_path) }
+ let(:generated_html) { helper.markdown(text_with_relative_path, requested_path: requested_path, ref: ref) }
subject { Nokogiri::HTML.parse(generated_html) }
- context 'when requested_path is provided in the context' do
+ context 'when requested_path is provided, but ref isn\'t' do
let(:requested_path) { 'files/images/README.md' }
+ let(:ref) { nil }
it 'returns the correct HTML for the image' do
expanded_path = "/#{project.full_path}/-/raw/master/files/images/#{image_file}"
@@ -110,13 +111,43 @@ describe MarkupHelper do
end
end
- context 'when requested_path parameter is not provided' do
+ context 'when requested_path and ref parameters are both provided' do
+ let(:requested_path) { 'files/images/README.md' }
+ let(:ref) { 'other_branch' }
+
+ it 'returns the correct HTML for the image' do
+ project.repository.create_branch('other_branch')
+
+ expanded_path = "/#{project.full_path}/-/raw/#{ref}/files/images/#{image_file}"
+
+ expect(subject.css('a')[0].attr('href')).to eq(expanded_path)
+ expect(subject.css('img')[0].attr('data-src')).to eq(expanded_path)
+ end
+ end
+
+ context 'when ref is provided, but requested_path isn\'t' do
+ let(:ref) { 'other_branch' }
+ let(:requested_path) { nil }
+
+ it 'returns the correct HTML for the image' do
+ project.repository.create_branch('other_branch')
+
+ expanded_path = "/#{project.full_path}/-/blob/#{ref}/./#{image_file}"
+
+ expect(subject.css('a')[0].attr('href')).to eq(expanded_path)
+ expect(subject.css('img')[0].attr('data-src')).to eq(expanded_path)
+ end
+ end
+
+ context 'when neither requested_path, nor ref parameter is provided' do
+ let(:ref) { nil }
let(:requested_path) { nil }
- it 'returns the link to the image path as a relative path' do
+ it 'returns the correct HTML for the image' do
expanded_path = "/#{project.full_path}/-/blob/master/./#{image_file}"
expect(subject.css('a')[0].attr('href')).to eq(expanded_path)
+ expect(subject.css('img')[0].attr('data-src')).to eq(expanded_path)
end
end
end
diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb
index 169c8707bf4..946ffcddae7 100644
--- a/spec/helpers/members_helper_spec.rb
+++ b/spec/helpers/members_helper_spec.rb
@@ -22,6 +22,17 @@ describe MembersHelper do
it { expect(remove_member_message(group_member_invite)).to eq "Are you sure you want to revoke the invitation for #{group_member_invite.invite_email} to join the #{group.name} group?" }
it { expect(remove_member_message(group_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{group.name} group?" }
it { expect(remove_member_message(group_member_request, user: requester)).to eq "Are you sure you want to withdraw your access request for the #{group.name} group?" }
+
+ context 'an accepted user invitation with no user associated' do
+ before do
+ group_member_invite.update(invite_email: "#{SecureRandom.hex}@example.com", invite_token: nil, user_id: nil)
+ end
+
+ it 'logs an exception and shows orphaned status' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(anything, hash_including(:member_id, :invite_email, :invite_accepted_at))
+ expect(remove_member_message(group_member_invite)).to eq "Are you sure you want to remove this orphaned member from the #{group.name} group and any subresources?"
+ end
+ end
end
describe '#remove_member_title' do
diff --git a/spec/helpers/milestones_helper_spec.rb b/spec/helpers/milestones_helper_spec.rb
index 3574066e03e..4ce7143bdf0 100644
--- a/spec/helpers/milestones_helper_spec.rb
+++ b/spec/helpers/milestones_helper_spec.rb
@@ -85,4 +85,19 @@ describe MilestonesHelper do
end
end
end
+
+ describe "#group_milestone_route" do
+ let(:group) { build_stubbed(:group) }
+ let(:subgroup) { build_stubbed(:group, parent: group, name: "Test Subgrp") }
+
+ context "when in subgroup" do
+ let(:milestone) { build_stubbed(:group_milestone, group: subgroup) }
+
+ it 'generates correct url despite assigned @group' do
+ assign(:group, group)
+ milestone_path = "/groups/#{subgroup.full_path}/-/milestones/#{milestone.iid}"
+ expect(helper.group_milestone_route(milestone)).to eq(milestone_path)
+ end
+ end
+ end
end
diff --git a/spec/helpers/nav_helper_spec.rb b/spec/helpers/nav_helper_spec.rb
index f92dca11136..ac1c6c62433 100644
--- a/spec/helpers/nav_helper_spec.rb
+++ b/spec/helpers/nav_helper_spec.rb
@@ -117,4 +117,27 @@ describe NavHelper, :do_not_mock_admin_mode do
it { is_expected.to all(be_a(String)) }
end
+
+ describe '#page_has_markdown?' do
+ using RSpec::Parameterized::TableSyntax
+
+ where path: %w(
+ merge_requests#show
+ projects/merge_requests/conflicts#show
+ issues#show
+ milestones#show
+ issues#designs
+ )
+
+ with_them do
+ before do
+ allow(helper).to receive(:current_path?).and_call_original
+ allow(helper).to receive(:current_path?).with(path).and_return(true)
+ end
+
+ subject { helper.page_has_markdown? }
+
+ it { is_expected.to eq(true) }
+ end
+ end
end
diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb
index c4ed99e56a0..7969cfd97b5 100644
--- a/spec/helpers/preferences_helper_spec.rb
+++ b/spec/helpers/preferences_helper_spec.rb
@@ -123,7 +123,7 @@ describe PreferencesHelper do
describe '#language_choices' do
it 'returns an array of all available languages' do
expect(helper.language_choices).to be_an(Array)
- expect(helper.language_choices.map(&:second)).to eq(Gitlab::I18n.available_locales)
+ expect(helper.language_choices.map(&:first)).to eq(Gitlab::I18n::AVAILABLE_LANGUAGES.values.sort)
end
end
diff --git a/spec/helpers/projects/alert_management_helper_spec.rb b/spec/helpers/projects/alert_management_helper_spec.rb
new file mode 100644
index 00000000000..078759de39c
--- /dev/null
+++ b/spec/helpers/projects/alert_management_helper_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Projects::AlertManagementHelper do
+ include Gitlab::Routing.url_helpers
+
+ let_it_be(:project, reload: true) { create(:project) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project_path) { project.full_path }
+
+ describe '#alert_management_data' do
+ let(:user_can_enable_alert_management) { true }
+ let(:setting_path) { edit_project_service_path(project, AlertsService) }
+
+ subject(:data) { helper.alert_management_data(current_user, project) }
+
+ before do
+ allow(helper)
+ .to receive(:can?)
+ .with(current_user, :admin_project, project)
+ .and_return(user_can_enable_alert_management)
+ end
+
+ context 'without alert_managements_setting' do
+ it 'returns index page configuration' do
+ expect(helper.alert_management_data(current_user, project)).to match(
+ 'project-path' => project_path,
+ 'enable-alert-management-path' => setting_path,
+ 'empty-alert-svg-path' => match_asset_path('/assets/illustrations/alert-management-empty-state.svg'),
+ 'user-can-enable-alert-management' => 'true',
+ 'alert-management-enabled' => 'false'
+ )
+ end
+ end
+
+ context 'with alerts service' do
+ let_it_be(:alerts_service) { create(:alerts_service, project: project) }
+
+ context 'when alerts service is active' do
+ it 'enables alert management' do
+ expect(data).to include(
+ 'alert-management-enabled' => 'true'
+ )
+ end
+ end
+
+ context 'when alerts service is inactive' do
+ it 'disables alert management' do
+ alerts_service.update(active: false)
+
+ expect(data).to include(
+ 'alert-management-enabled' => 'false'
+ )
+ end
+ end
+ end
+
+ context 'when user does not have requisite enablement permissions' do
+ let(:user_can_enable_alert_management) { false }
+
+ it 'shows error tracking enablement as disabled' do
+ expect(helper.alert_management_data(current_user, project)).to include(
+ 'user-can-enable-alert-management' => 'false'
+ )
+ end
+ end
+ end
+
+ describe '#alert_management_detail_data' do
+ let(:alert_id) { 1 }
+ let(:new_issue_path) { new_project_issue_path(project) }
+
+ it 'returns detail page configuration' do
+ expect(helper.alert_management_detail_data(project, alert_id)).to eq(
+ 'alert-id' => alert_id,
+ 'project-path' => project_path,
+ 'new-issue-path' => new_issue_path
+ )
+ end
+ end
+end
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 17e3f8f9c06..189ab1a8354 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -719,11 +719,7 @@ describe ProjectsHelper do
end
describe '#show_merge_request_count' do
- context 'when the feature flag is enabled' do
- before do
- stub_feature_flags(project_list_show_mr_count: true)
- end
-
+ context 'enabled flag' do
it 'returns true if compact mode is disabled' do
expect(helper.show_merge_request_count?).to be_truthy
end
@@ -733,22 +729,7 @@ describe ProjectsHelper do
end
end
- context 'when the feature flag is disabled' do
- before do
- stub_feature_flags(project_list_show_mr_count: false)
- end
-
- it 'always returns false' do
- expect(helper.show_merge_request_count?(disabled: false)).to be_falsy
- expect(helper.show_merge_request_count?(disabled: true)).to be_falsy
- end
- end
-
context 'disabled flag' do
- before do
- stub_feature_flags(project_list_show_mr_count: true)
- end
-
it 'returns false if disabled flag is true' do
expect(helper.show_merge_request_count?(disabled: true)).to be_falsey
end
@@ -760,11 +741,7 @@ describe ProjectsHelper do
end
describe '#show_issue_count?' do
- context 'when the feature flag is enabled' do
- before do
- stub_feature_flags(project_list_show_issue_count: true)
- end
-
+ context 'enabled flag' do
it 'returns true if compact mode is disabled' do
expect(helper.show_issue_count?).to be_truthy
end
@@ -774,22 +751,7 @@ describe ProjectsHelper do
end
end
- context 'when the feature flag is disabled' do
- before do
- stub_feature_flags(project_list_show_issue_count: false)
- end
-
- it 'always returns false' do
- expect(helper.show_issue_count?(disabled: false)).to be_falsy
- expect(helper.show_issue_count?(disabled: true)).to be_falsy
- end
- end
-
context 'disabled flag' do
- before do
- stub_feature_flags(project_list_show_issue_count: true)
- end
-
it 'returns false if disabled flag is true' do
expect(helper.show_issue_count?(disabled: true)).to be_falsey
end
diff --git a/spec/helpers/releases_helper_spec.rb b/spec/helpers/releases_helper_spec.rb
index 282758679cb..de4086e48db 100644
--- a/spec/helpers/releases_helper_spec.rb
+++ b/spec/helpers/releases_helper_spec.rb
@@ -54,7 +54,9 @@ describe ReleasesHelper do
markdown_docs_path
releases_page_path
update_release_api_docs_path
- release_assets_docs_path)
+ release_assets_docs_path
+ manage_milestones_path
+ new_milestone_path)
expect(helper.data_for_edit_release_page.keys).to eq(keys)
end
end
diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb
index 18c94602596..6a06b012c6c 100644
--- a/spec/helpers/search_helper_spec.rb
+++ b/spec/helpers/search_helper_spec.rb
@@ -112,7 +112,6 @@ describe SearchHelper do
'milestones' | 'milestone'
'notes' | 'comment'
'projects' | 'project'
- 'snippet_blobs' | 'snippet result'
'snippet_titles' | 'snippet'
'users' | 'user'
'wiki_blobs' | 'wiki result'
diff --git a/spec/helpers/snippets_helper_spec.rb b/spec/helpers/snippets_helper_spec.rb
index b5b431b5818..6fdf4f5cfb4 100644
--- a/spec/helpers/snippets_helper_spec.rb
+++ b/spec/helpers/snippets_helper_spec.rb
@@ -151,35 +151,4 @@ describe SnippetsHelper do
"<input type=\"text\" readonly=\"readonly\" class=\"js-snippet-url-area snippet-embed-input form-control\" data-url=\"#{url}\" value=\"<script src=&quot;#{url}.js&quot;></script>\" autocomplete=\"off\"></input>"
end
end
-
- describe '#snippet_file_name' do
- subject { helper.snippet_file_name(snippet) }
-
- where(:snippet_type, :flag_enabled, :trait, :filename) do
- [
- [:personal_snippet, false, nil, 'foo.txt'],
- [:personal_snippet, true, nil, 'foo.txt'],
- [:personal_snippet, false, :repository, 'foo.txt'],
- [:personal_snippet, true, :repository, '.gitattributes'],
-
- [:project_snippet, false, nil, 'foo.txt'],
- [:project_snippet, true, nil, 'foo.txt'],
- [:project_snippet, false, :repository, 'foo.txt'],
- [:project_snippet, true, :repository, '.gitattributes']
- ]
- end
-
- with_them do
- let(:snippet) { create(snippet_type, trait, file_name: 'foo.txt') }
-
- before do
- allow(helper).to receive(:current_user).and_return(snippet.author)
- stub_feature_flags(version_snippets: flag_enabled)
- end
-
- it 'returns the correct filename' do
- expect(subject).to eq filename
- end
- end
- end
end
diff --git a/spec/helpers/todos_helper_spec.rb b/spec/helpers/todos_helper_spec.rb
index 7c73b990338..b09e1e2b83b 100644
--- a/spec/helpers/todos_helper_spec.rb
+++ b/spec/helpers/todos_helper_spec.rb
@@ -1,8 +1,26 @@
# frozen_string_literal: true
-require "spec_helper"
+require 'spec_helper'
describe TodosHelper do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:author) { create(:user) }
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:design) { create(:design, issue: issue) }
+ let_it_be(:note) do
+ create(:note,
+ project: issue.project,
+ note: 'I am note, hear me roar')
+ end
+ let_it_be(:design_todo) do
+ create(:todo, :mentioned,
+ user: user,
+ project: issue.project,
+ target: design,
+ author: author,
+ note: note)
+ end
+
describe '#todos_count_format' do
it 'shows fuzzy count for 100 or more items' do
expect(helper.todos_count_format(100)).to eq '99+'
@@ -32,7 +50,56 @@ describe TodosHelper do
{ 'id' => projects.first.id, 'text' => projects.first.full_name }
]
- expect(JSON.parse(helper.todo_projects_options)).to match_array(expected_results)
+ expect(Gitlab::Json.parse(helper.todo_projects_options)).to match_array(expected_results)
+ end
+ end
+
+ describe '#todo_target_link' do
+ context 'when given a design' do
+ let(:todo) { design_todo }
+
+ it 'produces a good link' do
+ path = helper.todo_target_path(todo)
+ link = helper.todo_target_link(todo)
+ expected = "<a href=\"#{path}\">design #{design.to_reference}</a>"
+
+ expect(link).to eq(expected)
+ end
+ end
+ end
+
+ describe '#todo_target_path' do
+ context 'when given a design' do
+ let(:todo) { design_todo }
+
+ it 'responds with an appropriate path' do
+ path = helper.todo_target_path(todo)
+ issue_path = Gitlab::Routing.url_helpers
+ .project_issue_path(issue.project, issue)
+
+ expect(path).to eq("#{issue_path}/designs/#{design.filename}##{dom_id(design_todo.note)}")
+ end
+ end
+ end
+
+ describe '#todo_target_type_name' do
+ context 'when given a design todo' do
+ let(:todo) { design_todo }
+
+ it 'responds with an appropriate target type name' do
+ name = helper.todo_target_type_name(todo)
+
+ expect(name).to eq('design')
+ end
+ end
+ end
+
+ describe '#todo_types_options' do
+ it 'includes a match for a design todo' do
+ options = helper.todo_types_options
+ design_option = options.find { |o| o[:id] == design_todo.target_type }
+
+ expect(design_option).to include(text: 'Design')
end
end
end
diff --git a/spec/helpers/visibility_level_helper_spec.rb b/spec/helpers/visibility_level_helper_spec.rb
index debe4401308..b7a88ee5010 100644
--- a/spec/helpers/visibility_level_helper_spec.rb
+++ b/spec/helpers/visibility_level_helper_spec.rb
@@ -146,22 +146,22 @@ describe VisibilityLevelHelper do
using RSpec::Parameterized::TableSyntax
- PUBLIC = Gitlab::VisibilityLevel::PUBLIC
- INTERNAL = Gitlab::VisibilityLevel::INTERNAL
- PRIVATE = Gitlab::VisibilityLevel::PRIVATE
+ public_vis = Gitlab::VisibilityLevel::PUBLIC
+ internal_vis = Gitlab::VisibilityLevel::INTERNAL
+ private_vis = Gitlab::VisibilityLevel::PRIVATE
# This is a subset of all the permutations
where(:requested_level, :max_allowed, :global_default_level, :restricted_levels, :expected) do
- PUBLIC | PUBLIC | PUBLIC | [] | PUBLIC
- PUBLIC | PUBLIC | PUBLIC | [PUBLIC] | INTERNAL
- INTERNAL | PUBLIC | PUBLIC | [] | INTERNAL
- INTERNAL | PRIVATE | PRIVATE | [] | PRIVATE
- PRIVATE | PUBLIC | PUBLIC | [] | PRIVATE
- PUBLIC | PRIVATE | INTERNAL | [] | PRIVATE
- PUBLIC | INTERNAL | PUBLIC | [] | INTERNAL
- PUBLIC | PRIVATE | PUBLIC | [] | PRIVATE
- PUBLIC | INTERNAL | INTERNAL | [] | INTERNAL
- PUBLIC | PUBLIC | INTERNAL | [] | PUBLIC
+ public_vis | public_vis | public_vis | [] | public_vis
+ public_vis | public_vis | public_vis | [public_vis] | internal_vis
+ internal_vis | public_vis | public_vis | [] | internal_vis
+ internal_vis | private_vis | private_vis | [] | private_vis
+ private_vis | public_vis | public_vis | [] | private_vis
+ public_vis | private_vis | internal_vis | [] | private_vis
+ public_vis | internal_vis | public_vis | [] | internal_vis
+ public_vis | private_vis | public_vis | [] | private_vis
+ public_vis | internal_vis | internal_vis | [] | internal_vis
+ public_vis | public_vis | internal_vis | [] | public_vis
end
before do
diff --git a/spec/helpers/x509_helper_spec.rb b/spec/helpers/x509_helper_spec.rb
index dcdf57ce035..db3f6158195 100644
--- a/spec/helpers/x509_helper_spec.rb
+++ b/spec/helpers/x509_helper_spec.rb
@@ -57,4 +57,22 @@ describe X509Helper do
end
end
end
+
+ describe '#x509_signature?' do
+ let(:x509_signature) { create(:x509_commit_signature) }
+ let(:gpg_signature) { create(:gpg_signature) }
+
+ it 'detects a x509 signed commit' do
+ signature = Gitlab::X509::Signature.new(
+ X509Helpers::User1.signed_commit_signature,
+ X509Helpers::User1.signed_commit_base_data,
+ X509Helpers::User1.certificate_email,
+ X509Helpers::User1.signed_commit_time
+ )
+
+ expect(x509_signature?(x509_signature)).to be_truthy
+ expect(x509_signature?(signature)).to be_truthy
+ expect(x509_signature?(gpg_signature)).to be_falsey
+ end
+ end
end
diff --git a/spec/initializers/action_mailer_hooks_spec.rb b/spec/initializers/action_mailer_hooks_spec.rb
index 20f96f7e16c..03eee09f737 100644
--- a/spec/initializers/action_mailer_hooks_spec.rb
+++ b/spec/initializers/action_mailer_hooks_spec.rb
@@ -6,6 +6,10 @@ describe 'ActionMailer hooks' do
describe 'smime signature interceptor' do
before do
class_spy(ActionMailer::Base).as_stubbed_const
+
+ # rspec-rails calls ActionMailer::Base.deliveries.clear after every test
+ # https://github.com/rspec/rspec-rails/commit/71c12388e2bad78aaeea6443a393ede78341a7a3
+ allow(ActionMailer::Base).to receive_message_chain(:deliveries, :clear)
end
it 'is disabled by default' do
diff --git a/spec/initializers/lograge_spec.rb b/spec/initializers/lograge_spec.rb
index 48acdac74ac..c243217d2a2 100644
--- a/spec/initializers/lograge_spec.rb
+++ b/spec/initializers/lograge_spec.rb
@@ -123,7 +123,7 @@ describe 'lograge', type: :request do
let(:logger) do
Logger.new(log_output).tap { |logger| logger.formatter = ->(_, _, _, msg) { msg } }
end
- let(:log_data) { JSON.parse(log_output.string) }
+ let(:log_data) { Gitlab::Json.parse(log_output.string) }
before do
Lograge.logger = logger
diff --git a/spec/initializers/secret_token_spec.rb b/spec/initializers/secret_token_spec.rb
index c29f46e7779..b7979144c72 100644
--- a/spec/initializers/secret_token_spec.rb
+++ b/spec/initializers/secret_token_spec.rb
@@ -7,9 +7,8 @@ describe 'create_tokens' do
include StubENV
let(:secrets) { ActiveSupport::OrderedOptions.new }
-
- HEX_KEY = /\h{128}/.freeze
- RSA_KEY = /\A-----BEGIN RSA PRIVATE KEY-----\n.+\n-----END RSA PRIVATE KEY-----\n\Z/m.freeze
+ let(:hex_key) { /\h{128}/.freeze }
+ let(:rsa_key) { /\A-----BEGIN RSA PRIVATE KEY-----\n.+\n-----END RSA PRIVATE KEY-----\n\Z/m.freeze }
before do
allow(File).to receive(:write)
@@ -35,7 +34,7 @@ describe 'create_tokens' do
keys = secrets.values_at(:secret_key_base, :otp_key_base, :db_key_base)
expect(keys.uniq).to eq(keys)
- expect(keys).to all(match(HEX_KEY))
+ expect(keys).to all(match(hex_key))
end
it 'generates an RSA key for openid_connect_signing_key' do
@@ -44,7 +43,7 @@ describe 'create_tokens' do
keys = secrets.values_at(:openid_connect_signing_key)
expect(keys.uniq).to eq(keys)
- expect(keys).to all(match(RSA_KEY))
+ expect(keys).to all(match(rsa_key))
end
it 'warns about the secrets to add to secrets.yml' do
diff --git a/spec/initializers/zz_metrics_spec.rb b/spec/initializers/zz_metrics_spec.rb
index b9a1919ceae..f41a807f1eb 100644
--- a/spec/initializers/zz_metrics_spec.rb
+++ b/spec/initializers/zz_metrics_spec.rb
@@ -5,15 +5,11 @@ require 'spec_helper'
describe 'instrument_classes' do
let(:config) { double(:config) }
- let(:influx_sampler) { double(:influx_sampler) }
-
before do
allow(config).to receive(:instrument_method)
allow(config).to receive(:instrument_methods)
allow(config).to receive(:instrument_instance_method)
allow(config).to receive(:instrument_instance_methods)
- allow(Gitlab::Metrics::Samplers::InfluxSampler).to receive(:initialize_instance).and_return(influx_sampler)
- allow(influx_sampler).to receive(:start)
allow(Gitlab::Application).to receive(:configure)
end
diff --git a/spec/javascripts/ajax_loading_spinner_spec.js b/spec/javascripts/ajax_loading_spinner_spec.js
deleted file mode 100644
index 89195a4397f..00000000000
--- a/spec/javascripts/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 => {
- spyOn($, 'ajax').and.callFake(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 => {
- spyOn($, 'ajax').and.callFake(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/javascripts/avatar_helper_spec.js b/spec/javascripts/avatar_helper_spec.js
deleted file mode 100644
index c1ef08e0f1b..00000000000
--- a/spec/javascripts/avatar_helper_spec.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import { TEST_HOST } from 'spec/test_constants';
-import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility';
-import {
- DEFAULT_SIZE_CLASS,
- IDENTICON_BG_COUNT,
- renderAvatar,
- renderIdenticon,
- getIdenticonBackgroundClass,
- getIdenticonTitle,
-} from '~/helpers/avatar_helper';
-
-function matchAll(str) {
- return new RegExp(`^${str}$`);
-}
-
-describe('avatar_helper', () => {
- describe('getIdenticonBackgroundClass', () => {
- it('returns identicon bg class from id', () => {
- expect(getIdenticonBackgroundClass(1)).toEqual('bg2');
- });
-
- it(`wraps around if id is bigger than ${IDENTICON_BG_COUNT}`, () => {
- expect(getIdenticonBackgroundClass(IDENTICON_BG_COUNT + 4)).toEqual('bg5');
- expect(getIdenticonBackgroundClass(IDENTICON_BG_COUNT * 5 + 6)).toEqual('bg7');
- });
- });
-
- describe('getIdenticonTitle', () => {
- it('returns identicon title from name', () => {
- expect(getIdenticonTitle('Lorem')).toEqual('L');
- expect(getIdenticonTitle('dolar-sit-amit')).toEqual('D');
- expect(getIdenticonTitle('%-with-special-chars')).toEqual('%');
- });
-
- it('returns space if name is falsey', () => {
- expect(getIdenticonTitle('')).toEqual(' ');
- expect(getIdenticonTitle(null)).toEqual(' ');
- });
- });
-
- describe('renderIdenticon', () => {
- it('renders with the first letter as title and bg based on id', () => {
- const entity = {
- id: IDENTICON_BG_COUNT + 3,
- name: 'Xavior',
- };
- const options = {
- sizeClass: 's32',
- };
-
- const result = renderIdenticon(entity, options);
-
- expect(result).toHaveClass(`identicon ${options.sizeClass} bg4`);
- expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name)));
- });
-
- it('renders with defaults, if no options are given', () => {
- const entity = {
- id: 1,
- name: 'tanuki',
- };
-
- const result = renderIdenticon(entity);
-
- expect(result).toHaveClass(`identicon ${DEFAULT_SIZE_CLASS} bg2`);
- expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name)));
- });
- });
-
- describe('renderAvatar', () => {
- it('renders an image with the avatarUrl', () => {
- const avatarUrl = `${TEST_HOST}/not-real-assets/test.png`;
-
- const result = renderAvatar({
- avatar_url: avatarUrl,
- });
-
- expect(result).toBeMatchedBy('img');
- expect(result).toHaveAttr('src', avatarUrl);
- expect(result).toHaveClass(DEFAULT_SIZE_CLASS);
- });
-
- it('renders an identicon if no avatarUrl', () => {
- const entity = {
- id: 1,
- name: 'walrus',
- };
- const options = {
- sizeClass: 's16',
- };
-
- const result = renderAvatar(entity, options);
-
- expect(result).toHaveClass(`identicon ${options.sizeClass} bg2`);
- expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name)));
- });
- });
-});
diff --git a/spec/javascripts/bootstrap_linked_tabs_spec.js b/spec/javascripts/bootstrap_linked_tabs_spec.js
deleted file mode 100644
index 1d21637ceae..00000000000
--- a/spec/javascripts/bootstrap_linked_tabs_spec.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
-
-describe('Linked Tabs', () => {
- preloadFixtures('static/linked_tabs.html');
-
- beforeEach(() => {
- loadFixtures('static/linked_tabs.html');
- });
-
- describe('when is initialized', () => {
- beforeEach(() => {
- spyOn(window.history, 'replaceState').and.callFake(function() {});
- });
-
- it('should activate the tab correspondent to the given action', () => {
- // eslint-disable-next-line no-new
- new LinkedTabs({
- action: 'tab1',
- defaultAction: 'tab1',
- parentEl: '.linked-tabs',
- });
-
- expect(document.querySelector('#tab1').classList).toContain('active');
- });
-
- it('should active the default tab action when the action is show', () => {
- // eslint-disable-next-line no-new
- new LinkedTabs({
- action: 'show',
- defaultAction: 'tab1',
- parentEl: '.linked-tabs',
- });
-
- expect(document.querySelector('#tab1').classList).toContain('active');
- });
- });
-
- describe('on click', () => {
- it('should change the url according to the clicked tab', () => {
- const historySpy = spyOn(window.history, 'replaceState').and.callFake(() => {});
-
- const linkedTabs = new LinkedTabs({
- action: 'show',
- defaultAction: 'tab1',
- parentEl: '.linked-tabs',
- });
-
- const secondTab = document.querySelector('.linked-tabs li:nth-child(2) a');
- const newState =
- secondTab.getAttribute('href') +
- linkedTabs.currentLocation.search +
- linkedTabs.currentLocation.hash;
-
- secondTab.click();
-
- if (historySpy) {
- expect(historySpy).toHaveBeenCalledWith(
- {
- url: newState,
- },
- document.title,
- newState,
- );
- }
- });
- });
-});
diff --git a/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js b/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js
deleted file mode 100644
index a1377564073..00000000000
--- a/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js
+++ /dev/null
@@ -1,231 +0,0 @@
-import $ from 'jquery';
-import MockAdapter from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
-import AjaxFormVariableList from '~/ci_variable_list/ajax_variable_list';
-
-const VARIABLE_PATCH_ENDPOINT = 'http://test.host/frontend-fixtures/builds-project/-/variables';
-const HIDE_CLASS = 'hide';
-
-describe('AjaxFormVariableList', () => {
- preloadFixtures('projects/ci_cd_settings.html');
- preloadFixtures('projects/ci_cd_settings_with_variables.html');
-
- let container;
- let saveButton;
- let errorBox;
-
- let mock;
- let ajaxVariableList;
-
- beforeEach(() => {
- loadFixtures('projects/ci_cd_settings.html');
- container = document.querySelector('.js-ci-variable-list-section');
-
- mock = new MockAdapter(axios);
-
- const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section');
- saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button');
- errorBox = container.querySelector('.js-ci-variable-error-box');
- ajaxVariableList = new AjaxFormVariableList({
- container,
- formField: 'variables',
- saveButton,
- errorBox,
- saveEndpoint: container.dataset.saveEndpoint,
- maskableRegex: container.dataset.maskableRegex,
- });
-
- spyOn(ajaxVariableList, 'updateRowsWithPersistedVariables').and.callThrough();
- spyOn(ajaxVariableList.variableList, 'toggleEnableRow').and.callThrough();
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- describe('onSaveClicked', () => {
- it('shows loading spinner while waiting for the request', done => {
- const loadingIcon = saveButton.querySelector('.js-ci-variables-save-loading-icon');
-
- mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => {
- expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(false);
-
- return [200, {}];
- });
-
- expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true);
-
- ajaxVariableList
- .onSaveClicked()
- .then(() => {
- expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('calls `updateRowsWithPersistedVariables` with the persisted variables', done => {
- const variablesResponse = [{ id: 1, key: 'foo', value: 'bar' }];
- mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {
- variables: variablesResponse,
- });
-
- ajaxVariableList
- .onSaveClicked()
- .then(() => {
- expect(ajaxVariableList.updateRowsWithPersistedVariables).toHaveBeenCalledWith(
- variablesResponse,
- );
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('hides any previous error box', done => {
- mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200);
-
- expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
-
- ajaxVariableList
- .onSaveClicked()
- .then(() => {
- expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('disables remove buttons while waiting for the request', done => {
- mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => {
- expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(false);
-
- return [200, {}];
- });
-
- ajaxVariableList
- .onSaveClicked()
- .then(() => {
- expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(true);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('hides secret values', done => {
- mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {});
-
- const row = container.querySelector('.js-row');
- const valueInput = row.querySelector('.js-ci-variable-input-value');
- const valuePlaceholder = row.querySelector('.js-secret-value-placeholder');
-
- valueInput.value = 'bar';
- $(valueInput).trigger('input');
-
- expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(true);
- expect(valueInput.classList.contains(HIDE_CLASS)).toBe(false);
-
- ajaxVariableList
- .onSaveClicked()
- .then(() => {
- expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(false);
- expect(valueInput.classList.contains(HIDE_CLASS)).toBe(true);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('shows error box with validation errors', done => {
- const validationError = 'some validation error';
- mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(400, [validationError]);
-
- expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
-
- ajaxVariableList
- .onSaveClicked()
- .then(() => {
- expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(false);
- expect(errorBox.textContent.trim().replace(/\n+\s+/m, ' ')).toEqual(
- `Validation failed ${validationError}`,
- );
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('shows flash message when request fails', done => {
- mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(500);
-
- expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
-
- ajaxVariableList
- .onSaveClicked()
- .then(() => {
- expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
- describe('updateRowsWithPersistedVariables', () => {
- beforeEach(() => {
- loadFixtures('projects/ci_cd_settings_with_variables.html');
- container = document.querySelector('.js-ci-variable-list-section');
-
- const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section');
- saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button');
- errorBox = container.querySelector('.js-ci-variable-error-box');
- ajaxVariableList = new AjaxFormVariableList({
- container,
- formField: 'variables',
- saveButton,
- errorBox,
- saveEndpoint: container.dataset.saveEndpoint,
- });
- });
-
- it('removes variable that was removed', () => {
- expect(container.querySelectorAll('.js-row').length).toBe(3);
-
- container.querySelector('.js-row-remove-button').click();
-
- expect(container.querySelectorAll('.js-row').length).toBe(3);
-
- ajaxVariableList.updateRowsWithPersistedVariables([]);
-
- expect(container.querySelectorAll('.js-row').length).toBe(2);
- });
-
- it('updates new variable row with persisted ID', () => {
- const row = container.querySelector('.js-row:last-child');
- const idInput = row.querySelector('.js-ci-variable-input-id');
- const keyInput = row.querySelector('.js-ci-variable-input-key');
- const valueInput = row.querySelector('.js-ci-variable-input-value');
-
- keyInput.value = 'foo';
- $(keyInput).trigger('input');
- valueInput.value = 'bar';
- $(valueInput).trigger('input');
-
- expect(idInput.value).toEqual('');
-
- ajaxVariableList.updateRowsWithPersistedVariables([
- {
- id: 3,
- key: 'foo',
- value: 'bar',
- },
- ]);
-
- expect(idInput.value).toEqual('3');
- expect(row.dataset.isPersisted).toEqual('true');
- });
- });
-
- describe('maskableRegex', () => {
- it('takes in the regex provided by the data attribute', () => {
- expect(container.dataset.maskableRegex).toBe('^[a-zA-Z0-9_+=/@:.-]{8,}$');
- expect(ajaxVariableList.maskableRegex).toBe(container.dataset.maskableRegex);
- });
- });
-});
diff --git a/spec/javascripts/ci_variable_list/ci_variable_list_spec.js b/spec/javascripts/ci_variable_list/ci_variable_list_spec.js
deleted file mode 100644
index c0c3a83a44b..00000000000
--- a/spec/javascripts/ci_variable_list/ci_variable_list_spec.js
+++ /dev/null
@@ -1,294 +0,0 @@
-import $ from 'jquery';
-import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
-import VariableList from '~/ci_variable_list/ci_variable_list';
-
-const HIDE_CLASS = 'hide';
-
-describe('VariableList', () => {
- preloadFixtures('pipeline_schedules/edit.html');
- preloadFixtures('pipeline_schedules/edit_with_variables.html');
- preloadFixtures('projects/ci_cd_settings.html');
-
- let $wrapper;
- let variableList;
-
- describe('with only key/value inputs', () => {
- describe('with no variables', () => {
- beforeEach(() => {
- loadFixtures('pipeline_schedules/edit.html');
- $wrapper = $('.js-ci-variable-list-section');
-
- variableList = new VariableList({
- container: $wrapper,
- formField: 'schedule',
- });
- variableList.init();
- });
-
- it('should remove the row when clicking the remove button', () => {
- $wrapper.find('.js-row-remove-button').trigger('click');
-
- expect($wrapper.find('.js-row').length).toBe(0);
- });
-
- it('should add another row when editing the last rows key input', () => {
- const $row = $wrapper.find('.js-row');
- $row
- .find('.js-ci-variable-input-key')
- .val('foo')
- .trigger('input');
-
- expect($wrapper.find('.js-row').length).toBe(2);
-
- // Check for the correct default in the new row
- const $keyInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key');
-
- expect($keyInput.val()).toBe('');
- });
-
- it('should add another row when editing the last rows value textarea', () => {
- const $row = $wrapper.find('.js-row');
- $row
- .find('.js-ci-variable-input-value')
- .val('foo')
- .trigger('input');
-
- expect($wrapper.find('.js-row').length).toBe(2);
-
- // Check for the correct default in the new row
- const $valueInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key');
-
- expect($valueInput.val()).toBe('');
- });
-
- it('should remove empty row after blurring', () => {
- const $row = $wrapper.find('.js-row');
- $row
- .find('.js-ci-variable-input-key')
- .val('foo')
- .trigger('input');
-
- expect($wrapper.find('.js-row').length).toBe(2);
-
- $row
- .find('.js-ci-variable-input-key')
- .val('')
- .trigger('input')
- .trigger('blur');
-
- expect($wrapper.find('.js-row').length).toBe(1);
- });
- });
-
- describe('with persisted variables', () => {
- beforeEach(() => {
- loadFixtures('pipeline_schedules/edit_with_variables.html');
- $wrapper = $('.js-ci-variable-list-section');
-
- variableList = new VariableList({
- container: $wrapper,
- formField: 'schedule',
- });
- variableList.init();
- });
-
- it('should have "Reveal values" button initially when there are already variables', () => {
- expect($wrapper.find('.js-secret-value-reveal-button').text()).toBe('Reveal values');
- });
-
- it('should reveal hidden values', () => {
- const $row = $wrapper.find('.js-row:first-child');
- const $inputValue = $row.find('.js-ci-variable-input-value');
- const $placeholder = $row.find('.js-secret-value-placeholder');
-
- expect($placeholder.hasClass(HIDE_CLASS)).toBe(false);
- expect($inputValue.hasClass(HIDE_CLASS)).toBe(true);
-
- // Reveal values
- $wrapper.find('.js-secret-value-reveal-button').click();
-
- expect($placeholder.hasClass(HIDE_CLASS)).toBe(true);
- expect($inputValue.hasClass(HIDE_CLASS)).toBe(false);
- });
- });
- });
-
- describe('with all inputs(key, value, protected)', () => {
- beforeEach(() => {
- loadFixtures('projects/ci_cd_settings.html');
- $wrapper = $('.js-ci-variable-list-section');
-
- $wrapper.find('.js-ci-variable-input-protected').attr('data-default', 'false');
-
- variableList = new VariableList({
- container: $wrapper,
- formField: 'variables',
- });
- variableList.init();
- });
-
- it('should not add another row when editing the last rows protected checkbox', done => {
- const $row = $wrapper.find('.js-row:last-child');
- $row.find('.ci-variable-protected-item .js-project-feature-toggle').click();
-
- getSetTimeoutPromise()
- .then(() => {
- expect($wrapper.find('.js-row').length).toBe(1);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('should not add another row when editing the last rows masked checkbox', done => {
- const $row = $wrapper.find('.js-row:last-child');
- $row.find('.ci-variable-masked-item .js-project-feature-toggle').click();
-
- getSetTimeoutPromise()
- .then(() => {
- expect($wrapper.find('.js-row').length).toBe(1);
- })
- .then(done)
- .catch(done.fail);
- });
-
- describe('validateMaskability', () => {
- let $row;
-
- const maskingErrorElement = '.js-row:last-child .masking-validation-error';
-
- beforeEach(() => {
- $row = $wrapper.find('.js-row:last-child');
- $row.find('.ci-variable-masked-item .js-project-feature-toggle').click();
- });
-
- it('has a regex provided via a data attribute', () => {
- expect($wrapper.attr('data-maskable-regex')).toBe('^[a-zA-Z0-9_+=/@:.-]{8,}$');
- });
-
- it('allows values that are 8 characters long', done => {
- $row.find('.js-ci-variable-input-value').val('looooong');
-
- getSetTimeoutPromise()
- .then(() => {
- expect($wrapper.find(maskingErrorElement)).toHaveClass('hide');
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('rejects values that are shorter than 8 characters', done => {
- $row.find('.js-ci-variable-input-value').val('short');
-
- getSetTimeoutPromise()
- .then(() => {
- expect($wrapper.find(maskingErrorElement)).toBeVisible();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('allows values with base 64 characters', done => {
- $row.find('.js-ci-variable-input-value').val('abcABC123_+=/-');
-
- getSetTimeoutPromise()
- .then(() => {
- expect($wrapper.find(maskingErrorElement)).toHaveClass('hide');
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('rejects values with other special characters', done => {
- $row.find('.js-ci-variable-input-value').val('1234567$');
-
- getSetTimeoutPromise()
- .then(() => {
- expect($wrapper.find(maskingErrorElement)).toBeVisible();
- })
- .then(done)
- .catch(done.fail);
- });
- });
- });
-
- describe('toggleEnableRow method', () => {
- beforeEach(() => {
- loadFixtures('pipeline_schedules/edit_with_variables.html');
- $wrapper = $('.js-ci-variable-list-section');
-
- variableList = new VariableList({
- container: $wrapper,
- formField: 'variables',
- });
- variableList.init();
- });
-
- it('should disable all key inputs', () => {
- expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3);
-
- variableList.toggleEnableRow(false);
-
- expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3);
- });
-
- it('should disable all remove buttons', () => {
- expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3);
-
- variableList.toggleEnableRow(false);
-
- expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3);
- });
-
- it('should enable all remove buttons', () => {
- variableList.toggleEnableRow(false);
-
- expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3);
-
- variableList.toggleEnableRow(true);
-
- expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3);
- });
-
- it('should enable all key inputs', () => {
- variableList.toggleEnableRow(false);
-
- expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3);
-
- variableList.toggleEnableRow(true);
-
- expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3);
- });
- });
-
- describe('hideValues', () => {
- beforeEach(() => {
- loadFixtures('projects/ci_cd_settings.html');
- $wrapper = $('.js-ci-variable-list-section');
-
- variableList = new VariableList({
- container: $wrapper,
- formField: 'variables',
- });
- variableList.init();
- });
-
- it('should hide value input and show placeholder stars', () => {
- const $row = $wrapper.find('.js-row');
- const $inputValue = $row.find('.js-ci-variable-input-value');
- const $placeholder = $row.find('.js-secret-value-placeholder');
-
- $row
- .find('.js-ci-variable-input-value')
- .val('foo')
- .trigger('input');
-
- expect($placeholder.hasClass(HIDE_CLASS)).toBe(true);
- expect($inputValue.hasClass(HIDE_CLASS)).toBe(false);
-
- variableList.hideValues();
-
- expect($placeholder.hasClass(HIDE_CLASS)).toBe(false);
- expect($inputValue.hasClass(HIDE_CLASS)).toBe(true);
- });
- });
-});
diff --git a/spec/javascripts/close_reopen_report_toggle_spec.js b/spec/javascripts/close_reopen_report_toggle_spec.js
deleted file mode 100644
index 04a7ae7f429..00000000000
--- a/spec/javascripts/close_reopen_report_toggle_spec.js
+++ /dev/null
@@ -1,272 +0,0 @@
-/* eslint-disable jasmine/no-unsafe-spy */
-
-import CloseReopenReportToggle from '~/close_reopen_report_toggle';
-import DropLab from '~/droplab/drop_lab';
-
-describe('CloseReopenReportToggle', () => {
- describe('class constructor', () => {
- const dropdownTrigger = {};
- const dropdownList = {};
- const button = {};
- let commentTypeToggle;
-
- beforeEach(function() {
- commentTypeToggle = new CloseReopenReportToggle({
- dropdownTrigger,
- dropdownList,
- button,
- });
- });
-
- it('sets .dropdownTrigger', function() {
- expect(commentTypeToggle.dropdownTrigger).toBe(dropdownTrigger);
- });
-
- it('sets .dropdownList', function() {
- expect(commentTypeToggle.dropdownList).toBe(dropdownList);
- });
-
- it('sets .button', function() {
- expect(commentTypeToggle.button).toBe(button);
- });
- });
-
- describe('initDroplab', () => {
- let closeReopenReportToggle;
- const dropdownList = jasmine.createSpyObj('dropdownList', ['querySelector']);
- const dropdownTrigger = {};
- const button = {};
- const reopenItem = {};
- const closeItem = {};
- const config = {};
-
- beforeEach(() => {
- spyOn(DropLab.prototype, 'init');
- dropdownList.querySelector.and.returnValues(reopenItem, closeItem);
-
- closeReopenReportToggle = new CloseReopenReportToggle({
- dropdownTrigger,
- dropdownList,
- button,
- });
-
- spyOn(closeReopenReportToggle, 'setConfig').and.returnValue(config);
-
- closeReopenReportToggle.initDroplab();
- });
-
- it('sets .reopenItem and .closeItem', () => {
- expect(dropdownList.querySelector).toHaveBeenCalledWith('.reopen-item');
- expect(dropdownList.querySelector).toHaveBeenCalledWith('.close-item');
- expect(closeReopenReportToggle.reopenItem).toBe(reopenItem);
- expect(closeReopenReportToggle.closeItem).toBe(closeItem);
- });
-
- it('sets .droplab', () => {
- expect(closeReopenReportToggle.droplab).toEqual(jasmine.any(Object));
- });
-
- it('calls .setConfig', () => {
- expect(closeReopenReportToggle.setConfig).toHaveBeenCalled();
- });
-
- it('calls droplab.init', () => {
- expect(DropLab.prototype.init).toHaveBeenCalledWith(
- dropdownTrigger,
- dropdownList,
- jasmine.any(Array),
- config,
- );
- });
- });
-
- describe('updateButton', () => {
- let closeReopenReportToggle;
- const dropdownList = {};
- const dropdownTrigger = {};
- const button = jasmine.createSpyObj('button', ['blur']);
- const isClosed = true;
-
- beforeEach(() => {
- closeReopenReportToggle = new CloseReopenReportToggle({
- dropdownTrigger,
- dropdownList,
- button,
- });
-
- spyOn(closeReopenReportToggle, 'toggleButtonType');
-
- closeReopenReportToggle.updateButton(isClosed);
- });
-
- it('calls .toggleButtonType', () => {
- expect(closeReopenReportToggle.toggleButtonType).toHaveBeenCalledWith(isClosed);
- });
-
- it('calls .button.blur', () => {
- expect(closeReopenReportToggle.button.blur).toHaveBeenCalled();
- });
- });
-
- describe('toggleButtonType', () => {
- let closeReopenReportToggle;
- const dropdownList = {};
- const dropdownTrigger = {};
- const button = {};
- const isClosed = true;
- const showItem = jasmine.createSpyObj('showItem', ['click']);
- const hideItem = {};
- showItem.classList = jasmine.createSpyObj('classList', ['add', 'remove']);
- hideItem.classList = jasmine.createSpyObj('classList', ['add', 'remove']);
-
- beforeEach(() => {
- closeReopenReportToggle = new CloseReopenReportToggle({
- dropdownTrigger,
- dropdownList,
- button,
- });
-
- spyOn(closeReopenReportToggle, 'getButtonTypes').and.returnValue([showItem, hideItem]);
-
- closeReopenReportToggle.toggleButtonType(isClosed);
- });
-
- it('calls .getButtonTypes', () => {
- expect(closeReopenReportToggle.getButtonTypes).toHaveBeenCalledWith(isClosed);
- });
-
- it('removes hide class and add selected class to showItem, opposite for hideItem', () => {
- expect(showItem.classList.remove).toHaveBeenCalledWith('hidden');
- expect(showItem.classList.add).toHaveBeenCalledWith('droplab-item-selected');
- expect(hideItem.classList.add).toHaveBeenCalledWith('hidden');
- expect(hideItem.classList.remove).toHaveBeenCalledWith('droplab-item-selected');
- });
-
- it('clicks the showItem', () => {
- expect(showItem.click).toHaveBeenCalled();
- });
- });
-
- describe('getButtonTypes', () => {
- let closeReopenReportToggle;
- const dropdownList = {};
- const dropdownTrigger = {};
- const button = {};
- const reopenItem = {};
- const closeItem = {};
-
- beforeEach(() => {
- closeReopenReportToggle = new CloseReopenReportToggle({
- dropdownTrigger,
- dropdownList,
- button,
- });
-
- closeReopenReportToggle.reopenItem = reopenItem;
- closeReopenReportToggle.closeItem = closeItem;
- });
-
- it('returns reopenItem, closeItem if isClosed is true', () => {
- const buttonTypes = closeReopenReportToggle.getButtonTypes(true);
-
- expect(buttonTypes).toEqual([reopenItem, closeItem]);
- });
-
- it('returns closeItem, reopenItem if isClosed is false', () => {
- const buttonTypes = closeReopenReportToggle.getButtonTypes(false);
-
- expect(buttonTypes).toEqual([closeItem, reopenItem]);
- });
- });
-
- describe('setDisable', () => {
- let closeReopenReportToggle;
- const dropdownList = {};
- const dropdownTrigger = jasmine.createSpyObj('button', ['setAttribute', 'removeAttribute']);
- const button = jasmine.createSpyObj('button', ['setAttribute', 'removeAttribute']);
-
- beforeEach(() => {
- closeReopenReportToggle = new CloseReopenReportToggle({
- dropdownTrigger,
- dropdownList,
- button,
- });
- });
-
- it('disable .button and .dropdownTrigger if shouldDisable is true', () => {
- closeReopenReportToggle.setDisable(true);
-
- expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true');
- expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true');
- });
-
- it('disable .button and .dropdownTrigger if shouldDisable is undefined', () => {
- closeReopenReportToggle.setDisable();
-
- expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true');
- expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true');
- });
-
- it('enable .button and .dropdownTrigger if shouldDisable is false', () => {
- closeReopenReportToggle.setDisable(false);
-
- expect(button.removeAttribute).toHaveBeenCalledWith('disabled');
- expect(dropdownTrigger.removeAttribute).toHaveBeenCalledWith('disabled');
- });
- });
-
- describe('setConfig', () => {
- let closeReopenReportToggle;
- const dropdownList = {};
- const dropdownTrigger = {};
- const button = {};
- let config;
-
- beforeEach(() => {
- closeReopenReportToggle = new CloseReopenReportToggle({
- dropdownTrigger,
- dropdownList,
- button,
- });
-
- config = closeReopenReportToggle.setConfig();
- });
-
- it('returns a config object', () => {
- expect(config).toEqual({
- InputSetter: [
- {
- input: button,
- valueAttribute: 'data-text',
- inputAttribute: 'data-value',
- },
- {
- input: button,
- valueAttribute: 'data-text',
- inputAttribute: 'title',
- },
- {
- input: button,
- valueAttribute: 'data-button-class',
- inputAttribute: 'class',
- },
- {
- input: dropdownTrigger,
- valueAttribute: 'data-toggle-class',
- inputAttribute: 'class',
- },
- {
- input: button,
- valueAttribute: 'data-url',
- inputAttribute: 'href',
- },
- {
- input: button,
- valueAttribute: 'data-method',
- inputAttribute: 'data-method',
- },
- ],
- });
- });
- });
-});
diff --git a/spec/javascripts/commits_spec.js b/spec/javascripts/commits_spec.js
deleted file mode 100644
index 28b89157bd3..00000000000
--- a/spec/javascripts/commits_spec.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import $ from 'jquery';
-import 'vendor/jquery.endless-scroll';
-import MockAdapter from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
-import CommitsList from '~/commits';
-import Pager from '~/pager';
-
-describe('Commits List', () => {
- let commitsList;
-
- beforeEach(() => {
- setFixtures(`
- <form class="commits-search-form" action="/h5bp/html5-boilerplate/commits/master">
- <input id="commits-search">
- </form>
- <ol id="commits-list"></ol>
- `);
- spyOn(Pager, 'init').and.stub();
- commitsList = new CommitsList(25);
- });
-
- it('should be defined', () => {
- expect(CommitsList).toBeDefined();
- });
-
- describe('processCommits', () => {
- it('should join commit headers', () => {
- commitsList.$contentList = $(`
- <div>
- <li class="commit-header" data-day="2016-09-20">
- <span class="day">20 Sep, 2016</span>
- <span class="commits-count">1 commit</span>
- </li>
- <li class="commit"></li>
- </div>
- `);
-
- const data = `
- <li class="commit-header" data-day="2016-09-20">
- <span class="day">20 Sep, 2016</span>
- <span class="commits-count">1 commit</span>
- </li>
- <li class="commit"></li>
- `;
-
- // The last commit header should be removed
- // since the previous one has the same data-day value.
- expect(commitsList.processCommits(data).find('li.commit-header').length).toBe(0);
- });
- });
-
- describe('on entering input', () => {
- let ajaxSpy;
- let mock;
-
- beforeEach(() => {
- commitsList.searchField.val('');
-
- spyOn(window.history, 'replaceState').and.stub();
- mock = new MockAdapter(axios);
-
- mock.onGet('/h5bp/html5-boilerplate/commits/master').reply(200, {
- html: '<li>Result</li>',
- });
-
- ajaxSpy = spyOn(axios, 'get').and.callThrough();
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('should save the last search string', done => {
- commitsList.searchField.val('GitLab');
- commitsList
- .filterResults()
- .then(() => {
- expect(ajaxSpy).toHaveBeenCalled();
- expect(commitsList.lastSearch).toEqual('GitLab');
-
- done();
- })
- .catch(done.fail);
- });
-
- it('should not make ajax call if the input does not change', done => {
- commitsList
- .filterResults()
- .then(() => {
- expect(ajaxSpy).not.toHaveBeenCalled();
- expect(commitsList.lastSearch).toEqual('');
-
- done();
- })
- .catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/deploy_keys/components/action_btn_spec.js b/spec/javascripts/deploy_keys/components/action_btn_spec.js
deleted file mode 100644
index 5bf72cc0018..00000000000
--- a/spec/javascripts/deploy_keys/components/action_btn_spec.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import Vue from 'vue';
-import eventHub from '~/deploy_keys/eventhub';
-import actionBtn from '~/deploy_keys/components/action_btn.vue';
-
-describe('Deploy keys action btn', () => {
- const data = getJSONFixture('deploy_keys/keys.json');
- const deployKey = data.enabled_keys[0];
- let vm;
-
- beforeEach(done => {
- const ActionBtnComponent = Vue.extend({
- components: {
- actionBtn,
- },
- data() {
- return {
- deployKey,
- };
- },
- template: `
- <action-btn
- :deploy-key="deployKey"
- type="enable">
- Enable
- </action-btn>`,
- });
-
- vm = new ActionBtnComponent().$mount();
-
- Vue.nextTick()
- .then(done)
- .catch(done.fail);
- });
-
- it('renders the default slot', () => {
- expect(vm.$el.textContent.trim()).toBe('Enable');
- });
-
- it('sends eventHub event with btn type', done => {
- spyOn(eventHub, '$emit');
-
- vm.$el.click();
-
- Vue.nextTick(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('enable.key', deployKey, jasmine.anything());
-
- done();
- });
- });
-
- it('shows loading spinner after click', done => {
- vm.$el.click();
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.fa')).toBeDefined();
-
- done();
- });
- });
-
- it('disables button after click', done => {
- vm.$el.click();
-
- Vue.nextTick(() => {
- expect(vm.$el.classList.contains('disabled')).toBeTruthy();
-
- expect(vm.$el.getAttribute('disabled')).toBe('disabled');
-
- done();
- });
- });
-});
diff --git a/spec/javascripts/deploy_keys/components/app_spec.js b/spec/javascripts/deploy_keys/components/app_spec.js
deleted file mode 100644
index c9a9814d122..00000000000
--- a/spec/javascripts/deploy_keys/components/app_spec.js
+++ /dev/null
@@ -1,155 +0,0 @@
-import Vue from 'vue';
-import MockAdapter from 'axios-mock-adapter';
-import { TEST_HOST } from 'spec/test_constants';
-import axios from '~/lib/utils/axios_utils';
-import eventHub from '~/deploy_keys/eventhub';
-import deployKeysApp from '~/deploy_keys/components/app.vue';
-
-describe('Deploy keys app component', () => {
- const data = getJSONFixture('deploy_keys/keys.json');
- let vm;
- let mock;
-
- beforeEach(done => {
- // set up axios mock before component
- mock = new MockAdapter(axios);
- mock.onGet(`${TEST_HOST}/dummy/`).replyOnce(200, data);
-
- const Component = Vue.extend(deployKeysApp);
-
- vm = new Component({
- propsData: {
- endpoint: `${TEST_HOST}/dummy`,
- projectId: '8',
- },
- }).$mount();
-
- setTimeout(done);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('renders loading icon', done => {
- vm.store.keys = {};
- vm.isLoading = false;
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelectorAll('.deploy-keys .nav-links li').length).toBe(0);
-
- expect(vm.$el.querySelector('.fa-spinner')).toBeDefined();
-
- done();
- });
- });
-
- it('renders keys panels', () => {
- expect(vm.$el.querySelectorAll('.deploy-keys .nav-links li').length).toBe(3);
- });
-
- it('renders the titles with keys count', () => {
- const textContent = selector => {
- const element = vm.$el.querySelector(`${selector}`);
-
- expect(element).not.toBeNull();
- return element.textContent.trim();
- };
-
- expect(textContent('.js-deployKeys-tab-enabled_keys')).toContain('Enabled deploy keys');
- expect(textContent('.js-deployKeys-tab-available_project_keys')).toContain(
- 'Privately accessible deploy keys',
- );
-
- expect(textContent('.js-deployKeys-tab-public_keys')).toContain(
- 'Publicly accessible deploy keys',
- );
-
- expect(textContent('.js-deployKeys-tab-enabled_keys .badge')).toBe(
- `${vm.store.keys.enabled_keys.length}`,
- );
-
- expect(textContent('.js-deployKeys-tab-available_project_keys .badge')).toBe(
- `${vm.store.keys.available_project_keys.length}`,
- );
-
- expect(textContent('.js-deployKeys-tab-public_keys .badge')).toBe(
- `${vm.store.keys.public_keys.length}`,
- );
- });
-
- it('does not render key panels when keys object is empty', done => {
- vm.store.keys = {};
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelectorAll('.deploy-keys .nav-links li').length).toBe(0);
-
- done();
- });
- });
-
- it('re-fetches deploy keys when enabling a key', done => {
- const key = data.public_keys[0];
-
- spyOn(vm.service, 'getKeys');
- spyOn(vm.service, 'enableKey').and.callFake(() => Promise.resolve());
-
- eventHub.$emit('enable.key', key);
-
- Vue.nextTick(() => {
- expect(vm.service.enableKey).toHaveBeenCalledWith(key.id);
- expect(vm.service.getKeys).toHaveBeenCalled();
- done();
- });
- });
-
- it('re-fetches deploy keys when disabling a key', done => {
- const key = data.public_keys[0];
-
- spyOn(window, 'confirm').and.returnValue(true);
- spyOn(vm.service, 'getKeys');
- spyOn(vm.service, 'disableKey').and.callFake(() => Promise.resolve());
-
- eventHub.$emit('disable.key', key);
-
- Vue.nextTick(() => {
- expect(vm.service.disableKey).toHaveBeenCalledWith(key.id);
- expect(vm.service.getKeys).toHaveBeenCalled();
- done();
- });
- });
-
- it('calls disableKey when removing a key', done => {
- const key = data.public_keys[0];
-
- spyOn(window, 'confirm').and.returnValue(true);
- spyOn(vm.service, 'getKeys');
- spyOn(vm.service, 'disableKey').and.callFake(() => Promise.resolve());
-
- eventHub.$emit('remove.key', key);
-
- Vue.nextTick(() => {
- expect(vm.service.disableKey).toHaveBeenCalledWith(key.id);
- expect(vm.service.getKeys).toHaveBeenCalled();
- done();
- });
- });
-
- it('hasKeys returns true when there are keys', () => {
- expect(vm.hasKeys).toEqual(3);
- });
-
- it('resets disable button loading state', done => {
- spyOn(window, 'confirm').and.returnValue(false);
-
- const btn = vm.$el.querySelector('.btn-warning');
-
- btn.click();
-
- Vue.nextTick(() => {
- expect(btn.querySelector('.btn-warning')).not.toExist();
-
- done();
- });
- });
-});
diff --git a/spec/javascripts/deploy_keys/components/key_spec.js b/spec/javascripts/deploy_keys/components/key_spec.js
deleted file mode 100644
index 7117dc4a9ee..00000000000
--- a/spec/javascripts/deploy_keys/components/key_spec.js
+++ /dev/null
@@ -1,157 +0,0 @@
-import Vue from 'vue';
-import DeployKeysStore from '~/deploy_keys/store';
-import key from '~/deploy_keys/components/key.vue';
-import { getTimeago } from '~/lib/utils/datetime_utility';
-
-describe('Deploy keys key', () => {
- let vm;
- const KeyComponent = Vue.extend(key);
- const data = getJSONFixture('deploy_keys/keys.json');
- const createComponent = deployKey => {
- const store = new DeployKeysStore();
- store.keys = data;
-
- vm = new KeyComponent({
- propsData: {
- deployKey,
- store,
- endpoint: 'https://test.host/dummy/endpoint',
- },
- }).$mount();
- };
-
- describe('enabled key', () => {
- const deployKey = data.enabled_keys[0];
-
- beforeEach(done => {
- createComponent(deployKey);
-
- setTimeout(done);
- });
-
- it('renders the keys title', () => {
- expect(vm.$el.querySelector('.title').textContent.trim()).toContain('My title');
- });
-
- it('renders human friendly formatted created date', () => {
- expect(vm.$el.querySelector('.key-created-at').textContent.trim()).toBe(
- `${getTimeago().format(deployKey.created_at)}`,
- );
- });
-
- it('shows pencil button for editing', () => {
- expect(vm.$el.querySelector('.btn .ic-pencil')).toExist();
- });
-
- it('shows disable button when the project is not deletable', () => {
- expect(vm.$el.querySelector('.btn .ic-cancel')).toExist();
- });
-
- it('shows remove button when the project is deletable', done => {
- vm.deployKey.destroyed_when_orphaned = true;
- vm.deployKey.almost_orphaned = true;
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.btn .ic-remove')).toExist();
- done();
- });
- });
- });
-
- describe('deploy key labels', () => {
- it('shows write access title when key has write access', done => {
- vm.deployKey.deploy_keys_projects[0].can_push = true;
-
- Vue.nextTick(() => {
- expect(
- vm.$el.querySelector('.deploy-project-label').getAttribute('data-original-title'),
- ).toBe('Write access allowed');
- done();
- });
- });
-
- it('does not show write access title when key has write access', done => {
- vm.deployKey.deploy_keys_projects[0].can_push = false;
-
- Vue.nextTick(() => {
- expect(
- vm.$el.querySelector('.deploy-project-label').getAttribute('data-original-title'),
- ).toBe('Read access only');
- done();
- });
- });
-
- it('shows expandable button if more than two projects', () => {
- const labels = vm.$el.querySelectorAll('.deploy-project-label');
-
- expect(labels.length).toBe(2);
- expect(labels[1].textContent).toContain('others');
- expect(labels[1].getAttribute('data-original-title')).toContain('Expand');
- });
-
- it('expands all project labels after click', done => {
- const { length } = vm.deployKey.deploy_keys_projects;
- vm.$el.querySelectorAll('.deploy-project-label')[1].click();
-
- Vue.nextTick(() => {
- const labels = vm.$el.querySelectorAll('.deploy-project-label');
-
- expect(labels.length).toBe(length);
- expect(labels[1].textContent).not.toContain(`+${length} others`);
- expect(labels[1].getAttribute('data-original-title')).not.toContain('Expand');
- done();
- });
- });
-
- it('shows two projects', done => {
- vm.deployKey.deploy_keys_projects = [...vm.deployKey.deploy_keys_projects].slice(0, 2);
-
- Vue.nextTick(() => {
- const labels = vm.$el.querySelectorAll('.deploy-project-label');
-
- expect(labels.length).toBe(2);
- expect(labels[1].textContent).toContain(
- vm.deployKey.deploy_keys_projects[1].project.full_name,
- );
- done();
- });
- });
- });
-
- describe('public keys', () => {
- const deployKey = data.public_keys[0];
-
- beforeEach(done => {
- createComponent(deployKey);
-
- setTimeout(done);
- });
-
- it('renders deploy keys without any enabled projects', done => {
- vm.deployKey.deploy_keys_projects = [];
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.deploy-project-list').textContent.trim()).toBe('None');
-
- done();
- });
- });
-
- it('shows enable button', () => {
- expect(vm.$el.querySelectorAll('.btn')[0].textContent.trim()).toBe('Enable');
- });
-
- it('shows pencil button for editing', () => {
- expect(vm.$el.querySelector('.btn .ic-pencil')).toExist();
- });
-
- it('shows disable button when key is enabled', done => {
- vm.store.keys.enabled_keys.push(deployKey);
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.btn .ic-cancel')).toExist();
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/deploy_keys/components/keys_panel_spec.js b/spec/javascripts/deploy_keys/components/keys_panel_spec.js
deleted file mode 100644
index f71f5ccf082..00000000000
--- a/spec/javascripts/deploy_keys/components/keys_panel_spec.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import Vue from 'vue';
-import DeployKeysStore from '~/deploy_keys/store';
-import deployKeysPanel from '~/deploy_keys/components/keys_panel.vue';
-
-describe('Deploy keys panel', () => {
- const data = getJSONFixture('deploy_keys/keys.json');
- let vm;
-
- beforeEach(done => {
- const DeployKeysPanelComponent = Vue.extend(deployKeysPanel);
- const store = new DeployKeysStore();
- store.keys = data;
-
- vm = new DeployKeysPanelComponent({
- propsData: {
- title: 'test',
- keys: data.enabled_keys,
- showHelpBox: true,
- store,
- endpoint: 'https://test.host/dummy/endpoint',
- },
- }).$mount();
-
- setTimeout(done);
- });
-
- it('renders list of keys', () => {
- expect(vm.$el.querySelectorAll('.deploy-key').length).toBe(vm.keys.length);
- });
-
- it('renders table header', () => {
- const tableHeader = vm.$el.querySelector('.table-row-header');
-
- expect(tableHeader).toExist();
- expect(tableHeader.textContent).toContain('Deploy key');
- expect(tableHeader.textContent).toContain('Project usage');
- expect(tableHeader.textContent).toContain('Created');
- });
-
- it('renders help box if keys are empty', done => {
- vm.keys = [];
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.settings-message')).toBeDefined();
-
- expect(vm.$el.querySelector('.settings-message').textContent.trim()).toBe(
- 'No deploy keys found. Create one with the form above.',
- );
-
- done();
- });
- });
-
- it('renders no table header if keys are empty', done => {
- vm.keys = [];
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.table-row-header')).not.toExist();
-
- done();
- });
- });
-});
diff --git a/spec/javascripts/diff_comments_store_spec.js b/spec/javascripts/diff_comments_store_spec.js
deleted file mode 100644
index a6d363ce88e..00000000000
--- a/spec/javascripts/diff_comments_store_spec.js
+++ /dev/null
@@ -1,141 +0,0 @@
-/* eslint-disable jasmine/no-global-setup, dot-notation, jasmine/no-expect-in-setup-teardown */
-/* global CommentsStore */
-
-import '~/diff_notes/models/discussion';
-import '~/diff_notes/models/note';
-import '~/diff_notes/stores/comments';
-
-function createDiscussion(noteId = 1, resolved = true) {
- CommentsStore.create({
- discussionId: 'a',
- noteId,
- canResolve: true,
- resolved,
- resolvedBy: 'test',
- authorName: 'test',
- authorAvatar: 'test',
- noteTruncated: 'test...',
- });
-}
-
-beforeEach(() => {
- CommentsStore.state = {};
-});
-
-describe('New discussion', () => {
- it('creates new discussion', () => {
- expect(Object.keys(CommentsStore.state).length).toBe(0);
- createDiscussion();
-
- expect(Object.keys(CommentsStore.state).length).toBe(1);
- });
-
- it('creates new note in discussion', () => {
- createDiscussion();
- createDiscussion(2);
-
- const discussion = CommentsStore.state['a'];
-
- expect(Object.keys(discussion.notes).length).toBe(2);
- });
-});
-
-describe('Get note', () => {
- beforeEach(() => {
- expect(Object.keys(CommentsStore.state).length).toBe(0);
- createDiscussion();
- });
-
- it('gets note by ID', () => {
- const note = CommentsStore.get('a', 1);
-
- expect(note).toBeDefined();
- expect(note.id).toBe(1);
- });
-});
-
-describe('Delete discussion', () => {
- beforeEach(() => {
- expect(Object.keys(CommentsStore.state).length).toBe(0);
- createDiscussion();
- });
-
- it('deletes discussion by ID', () => {
- CommentsStore.delete('a', 1);
-
- expect(Object.keys(CommentsStore.state).length).toBe(0);
- });
-
- it('deletes discussion when no more notes', () => {
- createDiscussion();
- createDiscussion(2);
-
- expect(Object.keys(CommentsStore.state).length).toBe(1);
- expect(Object.keys(CommentsStore.state['a'].notes).length).toBe(2);
-
- CommentsStore.delete('a', 1);
- CommentsStore.delete('a', 2);
-
- expect(Object.keys(CommentsStore.state).length).toBe(0);
- });
-});
-
-describe('Update note', () => {
- beforeEach(() => {
- expect(Object.keys(CommentsStore.state).length).toBe(0);
- createDiscussion();
- });
-
- it('updates note to be unresolved', () => {
- CommentsStore.update('a', 1, false, 'test');
-
- const note = CommentsStore.get('a', 1);
-
- expect(note.resolved).toBe(false);
- });
-});
-
-describe('Discussion resolved', () => {
- beforeEach(() => {
- expect(Object.keys(CommentsStore.state).length).toBe(0);
- createDiscussion();
- });
-
- it('is resolved with single note', () => {
- const discussion = CommentsStore.state['a'];
-
- expect(discussion.isResolved()).toBe(true);
- });
-
- it('is unresolved with 2 notes', () => {
- const discussion = CommentsStore.state['a'];
- createDiscussion(2, false);
-
- expect(discussion.isResolved()).toBe(false);
- });
-
- it('is resolved with 2 notes', () => {
- const discussion = CommentsStore.state['a'];
- createDiscussion(2);
-
- expect(discussion.isResolved()).toBe(true);
- });
-
- it('resolve all notes', () => {
- const discussion = CommentsStore.state['a'];
- createDiscussion(2, false);
-
- discussion.resolveAllNotes();
-
- expect(discussion.isResolved()).toBe(true);
- });
-
- it('unresolve all notes', () => {
- const discussion = CommentsStore.state['a'];
- createDiscussion(2);
-
- discussion.unResolveAllNotes();
-
- expect(discussion.isResolved()).toBe(false);
- });
-});
diff --git a/spec/javascripts/diffs/create_diffs_store.js b/spec/javascripts/diffs/create_diffs_store.js
deleted file mode 100644
index 9df057dd8b2..00000000000
--- a/spec/javascripts/diffs/create_diffs_store.js
+++ /dev/null
@@ -1,5 +0,0 @@
-// No new code should be added to this file. Instead, modify the
-// file this one re-exports from. For more detail about why, see:
-// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/31349
-
-export { default } from '../../frontend/diffs/create_diffs_store';
diff --git a/spec/javascripts/diffs/mock_data/diff_discussions.js b/spec/javascripts/diffs/mock_data/diff_discussions.js
deleted file mode 100644
index 17586fddd0f..00000000000
--- a/spec/javascripts/diffs/mock_data/diff_discussions.js
+++ /dev/null
@@ -1,5 +0,0 @@
-// No new code should be added to this file. Instead, modify the
-// file this one re-exports from. For more detail about why, see:
-// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/31349
-
-export { default } from '../../../frontend/diffs/mock_data/diff_discussions';
diff --git a/spec/javascripts/diffs/mock_data/diff_file.js b/spec/javascripts/diffs/mock_data/diff_file.js
deleted file mode 100644
index 9dc365b7403..00000000000
--- a/spec/javascripts/diffs/mock_data/diff_file.js
+++ /dev/null
@@ -1,5 +0,0 @@
-// No new code should be added to this file. Instead, modify the
-// file this one re-exports from. For more detail about why, see:
-// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/31349
-
-export { default } from '../../../frontend/diffs/mock_data/diff_file';
diff --git a/spec/javascripts/diffs/mock_data/diff_file_unreadable.js b/spec/javascripts/diffs/mock_data/diff_file_unreadable.js
deleted file mode 100644
index 09a0dc61847..00000000000
--- a/spec/javascripts/diffs/mock_data/diff_file_unreadable.js
+++ /dev/null
@@ -1,5 +0,0 @@
-// No new code should be added to this file. Instead, modify the
-// file this one re-exports from. For more detail about why, see:
-// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/31349
-
-export { default } from '../../../frontend/diffs/mock_data/diff_file_unreadable';
diff --git a/spec/javascripts/diffs/mock_data/diff_with_commit.js b/spec/javascripts/diffs/mock_data/diff_with_commit.js
deleted file mode 100644
index c36b0239060..00000000000
--- a/spec/javascripts/diffs/mock_data/diff_with_commit.js
+++ /dev/null
@@ -1,7 +0,0 @@
-// No new code should be added to this file. Instead, modify the
-// file this one re-exports from. For more detail about why, see:
-// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/31349
-
-import getDiffWithCommit from '../../../frontend/diffs/mock_data/diff_with_commit';
-
-export default getDiffWithCommit;
diff --git a/spec/javascripts/diffs/mock_data/merge_request_diffs.js b/spec/javascripts/diffs/mock_data/merge_request_diffs.js
deleted file mode 100644
index de29eb7e560..00000000000
--- a/spec/javascripts/diffs/mock_data/merge_request_diffs.js
+++ /dev/null
@@ -1,7 +0,0 @@
-// No new code should be added to this file. Instead, modify the
-// file this one re-exports from. For more detail about why, see:
-// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/31349
-
-import diffsMockData from '../../../frontend/diffs/mock_data/merge_request_diffs';
-
-export default diffsMockData;
diff --git a/spec/javascripts/dirty_submit/dirty_submit_collection_spec.js b/spec/javascripts/dirty_submit/dirty_submit_collection_spec.js
deleted file mode 100644
index 47be0b3ce9d..00000000000
--- a/spec/javascripts/dirty_submit/dirty_submit_collection_spec.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import DirtySubmitCollection from '~/dirty_submit/dirty_submit_collection';
-import { setInputValue, createForm } from './helper';
-
-describe('DirtySubmitCollection', () => {
- it('disables submits until there are changes', done => {
- const testElementsCollection = [createForm(), createForm()];
- const forms = testElementsCollection.map(testElements => testElements.form);
-
- new DirtySubmitCollection(forms); // eslint-disable-line no-new
-
- testElementsCollection.forEach(testElements => {
- const { input, submit } = testElements;
- const originalValue = input.value;
-
- expect(submit.disabled).toBe(true);
-
- return setInputValue(input, `${originalValue} changes`)
- .then(() => {
- expect(submit.disabled).toBe(false);
- })
- .then(() => setInputValue(input, originalValue))
- .then(() => {
- expect(submit.disabled).toBe(true);
- })
- .then(done)
- .catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/dirty_submit/dirty_submit_form_spec.js b/spec/javascripts/dirty_submit/dirty_submit_form_spec.js
deleted file mode 100644
index 42f806fa1bf..00000000000
--- a/spec/javascripts/dirty_submit/dirty_submit_form_spec.js
+++ /dev/null
@@ -1,114 +0,0 @@
-import { range as rge } from 'lodash';
-import DirtySubmitForm from '~/dirty_submit/dirty_submit_form';
-import { getInputValue, setInputValue, createForm } from './helper';
-
-function expectToToggleDisableOnDirtyUpdate(submit, input) {
- const originalValue = getInputValue(input);
-
- expect(submit.disabled).toBe(true);
-
- return setInputValue(input, `${originalValue} changes`)
- .then(() => expect(submit.disabled).toBe(false))
- .then(() => setInputValue(input, originalValue))
- .then(() => expect(submit.disabled).toBe(true));
-}
-
-describe('DirtySubmitForm', () => {
- const originalThrottleDuration = DirtySubmitForm.THROTTLE_DURATION;
-
- describe('submit button tests', () => {
- beforeEach(() => {
- DirtySubmitForm.THROTTLE_DURATION = 0;
- });
-
- afterEach(() => {
- DirtySubmitForm.THROTTLE_DURATION = originalThrottleDuration;
- });
-
- it('disables submit until there are changes', done => {
- const { form, input, submit } = createForm();
-
- new DirtySubmitForm(form); // eslint-disable-line no-new
-
- return expectToToggleDisableOnDirtyUpdate(submit, input)
- .then(done)
- .catch(done.fail);
- });
-
- it('disables submit until there are changes when initializing with a falsy value', done => {
- const { form, input, submit } = createForm();
- input.value = '';
-
- new DirtySubmitForm(form); // eslint-disable-line no-new
-
- return expectToToggleDisableOnDirtyUpdate(submit, input)
- .then(done)
- .catch(done.fail);
- });
-
- it('disables submit until there are changes for radio inputs', done => {
- const { form, input, submit } = createForm('radio');
-
- new DirtySubmitForm(form); // eslint-disable-line no-new
-
- return expectToToggleDisableOnDirtyUpdate(submit, input)
- .then(done)
- .catch(done.fail);
- });
-
- it('disables submit until there are changes for checkbox inputs', done => {
- const { form, input, submit } = createForm('checkbox');
-
- new DirtySubmitForm(form); // eslint-disable-line no-new
-
- return expectToToggleDisableOnDirtyUpdate(submit, input)
- .then(done)
- .catch(done.fail);
- });
- });
-
- describe('throttling tests', () => {
- beforeEach(() => {
- jasmine.clock().install();
- jasmine.clock().mockDate();
- DirtySubmitForm.THROTTLE_DURATION = 100;
- });
-
- afterEach(() => {
- jasmine.clock().uninstall();
- DirtySubmitForm.THROTTLE_DURATION = originalThrottleDuration;
- });
-
- it('throttles updates when rapid changes are made to a single form element', () => {
- const { form, input } = createForm();
- const updateDirtyInputSpy = spyOn(new DirtySubmitForm(form), 'updateDirtyInput');
-
- rge(10).forEach(i => {
- setInputValue(input, `change ${i}`, false);
- });
-
- jasmine.clock().tick(101);
-
- expect(updateDirtyInputSpy).toHaveBeenCalledTimes(2);
- });
-
- it('does not throttle updates when rapid changes are made to different form elements', () => {
- const form = document.createElement('form');
- const range = rge(10);
- range.forEach(i => {
- form.innerHTML += `<input type="text" name="input-${i}" class="js-input-${i}"/>`;
- });
-
- const updateDirtyInputSpy = spyOn(new DirtySubmitForm(form), 'updateDirtyInput');
-
- range.forEach(i => {
- const input = form.querySelector(`.js-input-${i}`);
- setInputValue(input, `change`, false);
- });
-
- jasmine.clock().tick(101);
-
- expect(updateDirtyInputSpy).toHaveBeenCalledTimes(range.length);
- });
- });
-});
diff --git a/spec/javascripts/dirty_submit/helper.js b/spec/javascripts/dirty_submit/helper.js
deleted file mode 100644
index b51783cb915..00000000000
--- a/spec/javascripts/dirty_submit/helper.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import DirtySubmitForm from '~/dirty_submit/dirty_submit_form';
-import setTimeoutPromiseHelper from '../helpers/set_timeout_promise_helper';
-
-function isCheckableType(type) {
- return /^(radio|checkbox)$/.test(type);
-}
-
-export function setInputValue(element, value) {
- const { type } = element;
- let eventType;
-
- if (isCheckableType(type)) {
- element.checked = !element.checked;
- eventType = 'change';
- } else {
- element.value = value;
- eventType = 'input';
- }
-
- element.dispatchEvent(
- new Event(eventType, {
- bubbles: true,
- }),
- );
-
- return setTimeoutPromiseHelper(DirtySubmitForm.THROTTLE_DURATION);
-}
-
-export function getInputValue(input) {
- return isCheckableType(input.type) ? input.checked : input.value;
-}
-
-export function createForm(type = 'text') {
- const form = document.createElement('form');
- form.innerHTML = `
- <input type="${type}" name="${type}" class="js-input"/>
- <button type="submit" class="js-dirty-submit"></button>
- `;
-
- const input = form.querySelector('.js-input');
- const submit = form.querySelector('.js-dirty-submit');
-
- return {
- form,
- input,
- submit,
- };
-}
diff --git a/spec/javascripts/editor/editor_lite_spec.js b/spec/javascripts/editor/editor_lite_spec.js
deleted file mode 100644
index 106264aa13f..00000000000
--- a/spec/javascripts/editor/editor_lite_spec.js
+++ /dev/null
@@ -1,160 +0,0 @@
-import { editor as monacoEditor, Uri } from 'monaco-editor';
-import Editor from '~/editor/editor_lite';
-import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
-
-describe('Base editor', () => {
- let editorEl;
- let editor;
- const blobContent = 'Foo Bar';
- const blobPath = 'test.md';
- const uri = new Uri('gitlab', false, blobPath);
- const fakeModel = { foo: 'bar' };
-
- beforeEach(() => {
- setFixtures('<div id="editor" data-editor-loading></div>');
- editorEl = document.getElementById('editor');
- editor = new Editor();
- });
-
- afterEach(() => {
- editor.dispose();
- editorEl.remove();
- });
-
- it('initializes Editor with basic properties', () => {
- expect(editor).toBeDefined();
- expect(editor.editorEl).toBe(null);
- expect(editor.blobContent).toEqual('');
- expect(editor.blobPath).toEqual('');
- });
-
- it('removes `editor-loading` data attribute from the target DOM element', () => {
- editor.createInstance({ el: editorEl });
-
- expect(editorEl.dataset.editorLoading).toBeUndefined();
- });
-
- describe('instance of the Editor', () => {
- let modelSpy;
- let instanceSpy;
- let setModel;
- let dispose;
-
- beforeEach(() => {
- setModel = jasmine.createSpy();
- dispose = jasmine.createSpy();
- modelSpy = spyOn(monacoEditor, 'createModel').and.returnValue(fakeModel);
- instanceSpy = spyOn(monacoEditor, 'create').and.returnValue({
- setModel,
- dispose,
- });
- });
-
- it('does nothing if no dom element is supplied', () => {
- editor.createInstance();
-
- expect(editor.editorEl).toBe(null);
- expect(editor.blobContent).toEqual('');
- expect(editor.blobPath).toEqual('');
-
- expect(modelSpy).not.toHaveBeenCalled();
- expect(instanceSpy).not.toHaveBeenCalled();
- expect(setModel).not.toHaveBeenCalled();
- });
-
- it('creates model to be supplied to Monaco editor', () => {
- editor.createInstance({ el: editorEl, blobPath, blobContent });
-
- expect(modelSpy).toHaveBeenCalledWith(blobContent, undefined, uri);
- expect(setModel).toHaveBeenCalledWith(fakeModel);
- });
-
- it('initializes the instance on a supplied DOM node', () => {
- editor.createInstance({ el: editorEl });
-
- expect(editor.editorEl).not.toBe(null);
- expect(instanceSpy).toHaveBeenCalledWith(editorEl, jasmine.anything());
- });
- });
-
- describe('implementation', () => {
- beforeEach(() => {
- editor.createInstance({ el: editorEl, blobPath, blobContent });
- });
-
- afterEach(() => {
- editor.model.dispose();
- });
-
- it('correctly proxies value from the model', () => {
- expect(editor.getValue()).toEqual(blobContent);
- });
-
- it('is capable of changing the language of the model', () => {
- const blobRenamedPath = 'test.js';
-
- expect(editor.model.getLanguageIdentifier().language).toEqual('markdown');
- editor.updateModelLanguage(blobRenamedPath);
-
- expect(editor.model.getLanguageIdentifier().language).toEqual('javascript');
- });
-
- it('falls back to plaintext if there is no language associated with an extension', () => {
- const blobRenamedPath = 'test.myext';
- const spy = spyOn(console, 'error');
-
- editor.updateModelLanguage(blobRenamedPath);
-
- expect(spy).not.toHaveBeenCalled();
- expect(editor.model.getLanguageIdentifier().language).toEqual('plaintext');
- });
- });
-
- describe('syntax highlighting theme', () => {
- let themeDefineSpy;
- let themeSetSpy;
- let defaultScheme;
-
- beforeEach(() => {
- themeDefineSpy = spyOn(monacoEditor, 'defineTheme');
- themeSetSpy = spyOn(monacoEditor, 'setTheme');
- defaultScheme = window.gon.user_color_scheme;
- });
-
- afterEach(() => {
- window.gon.user_color_scheme = defaultScheme;
- });
-
- it('sets default syntax highlighting theme', () => {
- const expectedTheme = themes.find(t => t.name === DEFAULT_THEME);
-
- editor = new Editor();
-
- expect(themeDefineSpy).toHaveBeenCalledWith(DEFAULT_THEME, expectedTheme.data);
- expect(themeSetSpy).toHaveBeenCalledWith(DEFAULT_THEME);
- });
-
- it('sets correct theme if it is set in users preferences', () => {
- const expectedTheme = themes.find(t => t.name !== DEFAULT_THEME);
-
- expect(expectedTheme.name).not.toBe(DEFAULT_THEME);
-
- window.gon.user_color_scheme = expectedTheme.name;
- editor = new Editor();
-
- expect(themeDefineSpy).toHaveBeenCalledWith(expectedTheme.name, expectedTheme.data);
- expect(themeSetSpy).toHaveBeenCalledWith(expectedTheme.name);
- });
-
- it('falls back to default theme if a selected one is not supported yet', () => {
- const name = 'non-existent-theme';
- const nonExistentTheme = { name };
-
- window.gon.user_color_scheme = nonExistentTheme.name;
- editor = new Editor();
-
- expect(themeDefineSpy).not.toHaveBeenCalled();
- expect(themeSetSpy).toHaveBeenCalledWith(DEFAULT_THEME);
- });
- });
-});
diff --git a/spec/javascripts/emoji_spec.js b/spec/javascripts/emoji_spec.js
deleted file mode 100644
index 3db4d9800f1..00000000000
--- a/spec/javascripts/emoji_spec.js
+++ /dev/null
@@ -1,486 +0,0 @@
-import { glEmojiTag } from '~/emoji';
-import isEmojiUnicodeSupported, {
- isFlagEmoji,
- isRainbowFlagEmoji,
- isKeycapEmoji,
- isSkinToneComboEmoji,
- isHorceRacingSkinToneComboEmoji,
- isPersonZwjEmoji,
-} from '~/emoji/support/is_emoji_unicode_supported';
-
-const emptySupportMap = {
- personZwj: false,
- horseRacing: false,
- flag: false,
- skinToneModifier: false,
- '9.0': false,
- '8.0': false,
- '7.0': false,
- 6.1: false,
- '6.0': false,
- 5.2: false,
- 5.1: false,
- 4.1: false,
- '4.0': false,
- 3.2: false,
- '3.0': false,
- 1.1: false,
-};
-
-const emojiFixtureMap = {
- bomb: {
- name: 'bomb',
- moji: '💣',
- unicodeVersion: '6.0',
- },
- construction_worker_tone5: {
- name: 'construction_worker_tone5',
- moji: '👷🏿',
- unicodeVersion: '8.0',
- },
- five: {
- name: 'five',
- moji: '5️⃣',
- unicodeVersion: '3.0',
- },
- grey_question: {
- name: 'grey_question',
- moji: '❔',
- unicodeVersion: '6.0',
- },
-};
-
-function markupToDomElement(markup) {
- const div = document.createElement('div');
- div.innerHTML = markup;
- return div.firstElementChild;
-}
-
-function testGlEmojiImageFallback(element, name, src) {
- expect(element.tagName.toLowerCase()).toBe('img');
- expect(element.getAttribute('src')).toBe(src);
- expect(element.getAttribute('title')).toBe(`:${name}:`);
- expect(element.getAttribute('alt')).toBe(`:${name}:`);
-}
-
-const defaults = {
- forceFallback: false,
- sprite: false,
-};
-
-function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options = {}) {
- const opts = Object.assign({}, defaults, options);
- expect(element.tagName.toLowerCase()).toBe('gl-emoji');
- expect(element.dataset.name).toBe(name);
- expect(element.dataset.fallbackSrc.length).toBeGreaterThan(0);
- expect(element.dataset.unicodeVersion).toBe(unicodeVersion);
-
- const fallbackSpriteClass = `emoji-${name}`;
- if (opts.sprite) {
- expect(element.dataset.fallbackSpriteClass).toBe(fallbackSpriteClass);
- }
-
- if (opts.forceFallback && opts.sprite) {
- expect(element.getAttribute('class')).toBe(`emoji-icon ${fallbackSpriteClass}`);
- }
-
- if (opts.forceFallback && !opts.sprite) {
- // Check for image fallback
- testGlEmojiImageFallback(element.firstElementChild, name, element.dataset.fallbackSrc);
- } else {
- // Otherwise make sure things are still unicode text
- expect(element.textContent.trim()).toBe(unicodeMoji);
- }
-}
-
-describe('gl_emoji', () => {
- describe('glEmojiTag', () => {
- it('bomb emoji', () => {
- const emojiKey = 'bomb';
- const markup = glEmojiTag(emojiFixtureMap[emojiKey].name);
- const glEmojiElement = markupToDomElement(markup);
- testGlEmojiElement(
- glEmojiElement,
- emojiFixtureMap[emojiKey].name,
- emojiFixtureMap[emojiKey].unicodeVersion,
- emojiFixtureMap[emojiKey].moji,
- );
- });
-
- it('bomb emoji with image fallback', () => {
- const emojiKey = 'bomb';
- const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
- forceFallback: true,
- });
- const glEmojiElement = markupToDomElement(markup);
- testGlEmojiElement(
- glEmojiElement,
- emojiFixtureMap[emojiKey].name,
- emojiFixtureMap[emojiKey].unicodeVersion,
- emojiFixtureMap[emojiKey].moji,
- {
- forceFallback: true,
- },
- );
- });
-
- it('bomb emoji with sprite fallback readiness', () => {
- const emojiKey = 'bomb';
- const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
- sprite: true,
- });
- const glEmojiElement = markupToDomElement(markup);
- testGlEmojiElement(
- glEmojiElement,
- emojiFixtureMap[emojiKey].name,
- emojiFixtureMap[emojiKey].unicodeVersion,
- emojiFixtureMap[emojiKey].moji,
- {
- sprite: true,
- },
- );
- });
-
- it('bomb emoji with sprite fallback', () => {
- const emojiKey = 'bomb';
- const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
- forceFallback: true,
- sprite: true,
- });
- const glEmojiElement = markupToDomElement(markup);
- testGlEmojiElement(
- glEmojiElement,
- emojiFixtureMap[emojiKey].name,
- emojiFixtureMap[emojiKey].unicodeVersion,
- emojiFixtureMap[emojiKey].moji,
- {
- forceFallback: true,
- sprite: true,
- },
- );
- });
-
- it('question mark when invalid emoji name given', () => {
- const name = 'invalid_emoji';
- const emojiKey = 'grey_question';
- const markup = glEmojiTag(name);
- const glEmojiElement = markupToDomElement(markup);
- testGlEmojiElement(
- glEmojiElement,
- emojiFixtureMap[emojiKey].name,
- emojiFixtureMap[emojiKey].unicodeVersion,
- emojiFixtureMap[emojiKey].moji,
- );
- });
-
- it('question mark with image fallback when invalid emoji name given', () => {
- const name = 'invalid_emoji';
- const emojiKey = 'grey_question';
- const markup = glEmojiTag(name, {
- forceFallback: true,
- });
- const glEmojiElement = markupToDomElement(markup);
- testGlEmojiElement(
- glEmojiElement,
- emojiFixtureMap[emojiKey].name,
- emojiFixtureMap[emojiKey].unicodeVersion,
- emojiFixtureMap[emojiKey].moji,
- {
- forceFallback: true,
- },
- );
- });
- });
-
- describe('isFlagEmoji', () => {
- it('should gracefully handle empty string', () => {
- expect(isFlagEmoji('')).toBeFalsy();
- });
-
- it('should detect flag_ac', () => {
- expect(isFlagEmoji('🇦🇨')).toBeTruthy();
- });
-
- it('should detect flag_us', () => {
- expect(isFlagEmoji('🇺🇸')).toBeTruthy();
- });
-
- it('should detect flag_zw', () => {
- expect(isFlagEmoji('🇿🇼')).toBeTruthy();
- });
-
- it('should not detect flags', () => {
- expect(isFlagEmoji('🎏')).toBeFalsy();
- });
-
- it('should not detect triangular_flag_on_post', () => {
- expect(isFlagEmoji('🚩')).toBeFalsy();
- });
-
- it('should not detect single letter', () => {
- expect(isFlagEmoji('🇦')).toBeFalsy();
- });
-
- it('should not detect >2 letters', () => {
- expect(isFlagEmoji('🇦🇧🇨')).toBeFalsy();
- });
- });
-
- describe('isRainbowFlagEmoji', () => {
- it('should gracefully handle empty string', () => {
- expect(isRainbowFlagEmoji('')).toBeFalsy();
- });
-
- it('should detect rainbow_flag', () => {
- expect(isRainbowFlagEmoji('🏳🌈')).toBeTruthy();
- });
-
- it("should not detect flag_white on its' own", () => {
- expect(isRainbowFlagEmoji('🏳')).toBeFalsy();
- });
-
- it("should not detect rainbow on its' own", () => {
- expect(isRainbowFlagEmoji('🌈')).toBeFalsy();
- });
-
- it('should not detect flag_white with something else', () => {
- expect(isRainbowFlagEmoji('🏳🔵')).toBeFalsy();
- });
- });
-
- describe('isKeycapEmoji', () => {
- it('should gracefully handle empty string', () => {
- expect(isKeycapEmoji('')).toBeFalsy();
- });
-
- it('should detect one(keycap)', () => {
- expect(isKeycapEmoji('1️⃣')).toBeTruthy();
- });
-
- it('should detect nine(keycap)', () => {
- expect(isKeycapEmoji('9️⃣')).toBeTruthy();
- });
-
- it('should not detect ten(keycap)', () => {
- expect(isKeycapEmoji('🔟')).toBeFalsy();
- });
-
- it('should not detect hash(keycap)', () => {
- expect(isKeycapEmoji('#⃣')).toBeFalsy();
- });
- });
-
- describe('isSkinToneComboEmoji', () => {
- it('should gracefully handle empty string', () => {
- expect(isSkinToneComboEmoji('')).toBeFalsy();
- });
-
- it('should detect hand_splayed_tone5', () => {
- expect(isSkinToneComboEmoji('🖐🏿')).toBeTruthy();
- });
-
- it('should not detect hand_splayed', () => {
- expect(isSkinToneComboEmoji('🖐')).toBeFalsy();
- });
-
- it('should detect lifter_tone1', () => {
- expect(isSkinToneComboEmoji('🏋🏻')).toBeTruthy();
- });
-
- it('should not detect lifter', () => {
- expect(isSkinToneComboEmoji('🏋')).toBeFalsy();
- });
-
- it('should detect rowboat_tone4', () => {
- expect(isSkinToneComboEmoji('🚣🏾')).toBeTruthy();
- });
-
- it('should not detect rowboat', () => {
- expect(isSkinToneComboEmoji('🚣')).toBeFalsy();
- });
-
- it('should not detect individual tone emoji', () => {
- expect(isSkinToneComboEmoji('🏻')).toBeFalsy();
- });
- });
-
- describe('isHorceRacingSkinToneComboEmoji', () => {
- it('should gracefully handle empty string', () => {
- expect(isHorceRacingSkinToneComboEmoji('')).toBeFalsy();
- });
-
- it('should detect horse_racing_tone2', () => {
- expect(isHorceRacingSkinToneComboEmoji('🏇🏼')).toBeTruthy();
- });
-
- it('should not detect horse_racing', () => {
- expect(isHorceRacingSkinToneComboEmoji('🏇')).toBeFalsy();
- });
- });
-
- describe('isPersonZwjEmoji', () => {
- it('should gracefully handle empty string', () => {
- expect(isPersonZwjEmoji('')).toBeFalsy();
- });
-
- it('should detect couple_mm', () => {
- expect(isPersonZwjEmoji('👨‍❤️‍👨')).toBeTruthy();
- });
-
- it('should not detect couple_with_heart', () => {
- expect(isPersonZwjEmoji('💑')).toBeFalsy();
- });
-
- it('should not detect couplekiss', () => {
- expect(isPersonZwjEmoji('💏')).toBeFalsy();
- });
-
- it('should detect family_mmb', () => {
- expect(isPersonZwjEmoji('👨‍👨‍👦')).toBeTruthy();
- });
-
- it('should detect family_mwgb', () => {
- expect(isPersonZwjEmoji('👨‍👩‍👧‍👦')).toBeTruthy();
- });
-
- it('should not detect family', () => {
- expect(isPersonZwjEmoji('👪')).toBeFalsy();
- });
-
- it('should detect kiss_ww', () => {
- expect(isPersonZwjEmoji('👩‍❤️‍💋‍👩')).toBeTruthy();
- });
-
- it('should not detect girl', () => {
- expect(isPersonZwjEmoji('👧')).toBeFalsy();
- });
-
- it('should not detect girl_tone5', () => {
- expect(isPersonZwjEmoji('👧🏿')).toBeFalsy();
- });
-
- it('should not detect man', () => {
- expect(isPersonZwjEmoji('👨')).toBeFalsy();
- });
-
- it('should not detect woman', () => {
- expect(isPersonZwjEmoji('👩')).toBeFalsy();
- });
- });
-
- describe('isEmojiUnicodeSupported', () => {
- it('should gracefully handle empty string with unicode support', () => {
- const isSupported = isEmojiUnicodeSupported({ '1.0': true }, '', '1.0');
-
- expect(isSupported).toBeTruthy();
- });
-
- it('should gracefully handle empty string without unicode support', () => {
- const isSupported = isEmojiUnicodeSupported({}, '', '1.0');
-
- expect(isSupported).toBeFalsy();
- });
-
- it('bomb(6.0) with 6.0 support', () => {
- const emojiKey = 'bomb';
- const unicodeSupportMap = Object.assign({}, emptySupportMap, {
- '6.0': true,
- });
- const isSupported = isEmojiUnicodeSupported(
- unicodeSupportMap,
- emojiFixtureMap[emojiKey].moji,
- emojiFixtureMap[emojiKey].unicodeVersion,
- );
-
- expect(isSupported).toBeTruthy();
- });
-
- it('bomb(6.0) without 6.0 support', () => {
- const emojiKey = 'bomb';
- const unicodeSupportMap = emptySupportMap;
- const isSupported = isEmojiUnicodeSupported(
- unicodeSupportMap,
- emojiFixtureMap[emojiKey].moji,
- emojiFixtureMap[emojiKey].unicodeVersion,
- );
-
- expect(isSupported).toBeFalsy();
- });
-
- it('bomb(6.0) without 6.0 but with 9.0 support', () => {
- const emojiKey = 'bomb';
- const unicodeSupportMap = Object.assign({}, emptySupportMap, {
- '9.0': true,
- });
- const isSupported = isEmojiUnicodeSupported(
- unicodeSupportMap,
- emojiFixtureMap[emojiKey].moji,
- emojiFixtureMap[emojiKey].unicodeVersion,
- );
-
- expect(isSupported).toBeFalsy();
- });
-
- it('construction_worker_tone5(8.0) without skin tone modifier support', () => {
- const emojiKey = 'construction_worker_tone5';
- const unicodeSupportMap = Object.assign({}, emptySupportMap, {
- skinToneModifier: false,
- '9.0': true,
- '8.0': true,
- '7.0': true,
- 6.1: true,
- '6.0': true,
- 5.2: true,
- 5.1: true,
- 4.1: true,
- '4.0': true,
- 3.2: true,
- '3.0': true,
- 1.1: true,
- });
- const isSupported = isEmojiUnicodeSupported(
- unicodeSupportMap,
- emojiFixtureMap[emojiKey].moji,
- emojiFixtureMap[emojiKey].unicodeVersion,
- );
-
- expect(isSupported).toBeFalsy();
- });
-
- it('use native keycap on >=57 chrome', () => {
- const emojiKey = 'five';
- const unicodeSupportMap = Object.assign({}, emptySupportMap, {
- '3.0': true,
- meta: {
- isChrome: true,
- chromeVersion: 57,
- },
- });
- const isSupported = isEmojiUnicodeSupported(
- unicodeSupportMap,
- emojiFixtureMap[emojiKey].moji,
- emojiFixtureMap[emojiKey].unicodeVersion,
- );
-
- expect(isSupported).toBeTruthy();
- });
-
- it('fallback keycap on <57 chrome', () => {
- const emojiKey = 'five';
- const unicodeSupportMap = Object.assign({}, emptySupportMap, {
- '3.0': true,
- meta: {
- isChrome: true,
- chromeVersion: 50,
- },
- });
- const isSupported = isEmojiUnicodeSupported(
- unicodeSupportMap,
- emojiFixtureMap[emojiKey].moji,
- emojiFixtureMap[emojiKey].unicodeVersion,
- );
-
- expect(isSupported).toBeFalsy();
- });
- });
-});
diff --git a/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js b/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js
deleted file mode 100644
index ba35f7bf7c6..00000000000
--- a/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import $ from 'jquery';
-import MockAdapter from 'axios-mock-adapter';
-import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
-import axios from '~/lib/utils/axios_utils';
-import { getSelector, dismiss, inserted } from '~/feature_highlight/feature_highlight_helper';
-import { togglePopover } from '~/shared/popover';
-
-describe('feature highlight helper', () => {
- describe('getSelector', () => {
- it('returns js-feature-highlight selector', () => {
- const highlightId = 'highlightId';
-
- expect(getSelector(highlightId)).toEqual(
- `.js-feature-highlight[data-highlight=${highlightId}]`,
- );
- });
- });
-
- describe('dismiss', () => {
- let mock;
- const context = {
- hide: () => {},
- attr: () => '/-/callouts/dismiss',
- };
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
-
- spyOn(togglePopover, 'call').and.callFake(() => {});
- spyOn(context, 'hide').and.callFake(() => {});
- dismiss.call(context);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('calls persistent dismissal endpoint', done => {
- const spy = jasmine.createSpy('dismiss-endpoint-hit');
- mock.onPost('/-/callouts/dismiss').reply(spy);
-
- getSetTimeoutPromise()
- .then(() => {
- expect(spy).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('calls hide popover', () => {
- expect(togglePopover.call).toHaveBeenCalledWith(context, false);
- });
-
- it('calls hide', () => {
- expect(context.hide).toHaveBeenCalled();
- });
- });
-
- describe('inserted', () => {
- it('registers click event callback', done => {
- const context = {
- getAttribute: () => 'popoverId',
- dataset: {
- highlight: 'some-feature',
- },
- };
-
- spyOn($.fn, 'on').and.callFake(event => {
- expect(event).toEqual('click');
- done();
- });
- inserted.call(context);
- });
- });
-});
diff --git a/spec/javascripts/feature_highlight/feature_highlight_spec.js b/spec/javascripts/feature_highlight/feature_highlight_spec.js
deleted file mode 100644
index 40ac4bbb6a0..00000000000
--- a/spec/javascripts/feature_highlight/feature_highlight_spec.js
+++ /dev/null
@@ -1,141 +0,0 @@
-import $ from 'jquery';
-import MockAdapter from 'axios-mock-adapter';
-import * as featureHighlight from '~/feature_highlight/feature_highlight';
-import * as popover from '~/shared/popover';
-import axios from '~/lib/utils/axios_utils';
-
-describe('feature highlight', () => {
- beforeEach(() => {
- setFixtures(`
- <div>
- <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" data-dismiss-endpoint="/test" disabled>
- Trigger
- </div>
- </div>
- <div class="feature-highlight-popover-content">
- Content
- <div class="dismiss-feature-highlight">
- Dismiss
- </div>
- </div>
- `);
- });
-
- describe('setupFeatureHighlightPopover', () => {
- let mock;
- const selector = '.js-feature-highlight[data-highlight=test]';
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mock.onGet('/test').reply(200);
- spyOn(window, 'addEventListener');
- featureHighlight.setupFeatureHighlightPopover('test', 0);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('setup popover content', () => {
- const $popoverContent = $('.feature-highlight-popover-content');
- const outerHTML = $popoverContent.prop('outerHTML');
-
- expect($(selector).data('content')).toEqual(outerHTML);
- });
-
- it('setup mouseenter', () => {
- const toggleSpy = spyOn(popover.togglePopover, 'call');
- $(selector).trigger('mouseenter');
-
- expect(toggleSpy).toHaveBeenCalledWith(jasmine.any(Object), true);
- });
-
- it('setup debounced mouseleave', done => {
- const toggleSpy = spyOn(popover.togglePopover, 'call');
- $(selector).trigger('mouseleave');
-
- // Even though we've set the debounce to 0ms, setTimeout is needed for the debounce
- setTimeout(() => {
- expect(toggleSpy).toHaveBeenCalledWith(jasmine.any(Object), false);
- done();
- }, 0);
- });
-
- it('setup show.bs.popover', () => {
- $(selector).trigger('show.bs.popover');
-
- expect(window.addEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function), {
- once: true,
- });
- });
-
- it('removes disabled attribute', () => {
- expect($('.js-feature-highlight').is(':disabled')).toEqual(false);
- });
-
- it('displays popover', () => {
- expect(document.querySelector(selector).getAttribute('aria-describedby')).toBeFalsy();
- $(selector).trigger('mouseenter');
-
- expect(document.querySelector(selector).getAttribute('aria-describedby')).toBeTruthy();
- });
-
- it('toggles when clicked', () => {
- $(selector).trigger('mouseenter');
- const popoverId = $(selector).attr('aria-describedby');
- const toggleSpy = spyOn(popover.togglePopover, 'call');
-
- $(`#${popoverId} .dismiss-feature-highlight`).click();
-
- expect(toggleSpy).toHaveBeenCalled();
- });
- });
-
- describe('findHighestPriorityFeature', () => {
- beforeEach(() => {
- setFixtures(`
- <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
- <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
- <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
- `);
- });
-
- it('should pick the highest priority feature highlight', () => {
- setFixtures(`
- <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
- <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
- <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
- `);
-
- expect($('.js-feature-highlight').length).toBeGreaterThan(1);
- expect(featureHighlight.findHighestPriorityFeature()).toEqual('test-high-priority');
- });
-
- it('should work when no priority is set', () => {
- setFixtures(`
- <div class="js-feature-highlight" data-highlight="test" disabled></div>
- `);
-
- expect(featureHighlight.findHighestPriorityFeature()).toEqual('test');
- });
-
- it('should pick the highest priority feature highlight when some have no priority set', () => {
- setFixtures(`
- <div class="js-feature-highlight" data-highlight="test-no-priority1" disabled></div>
- <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
- <div class="js-feature-highlight" data-highlight="test-no-priority2" disabled></div>
- <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
- <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
- `);
-
- expect($('.js-feature-highlight').length).toBeGreaterThan(1);
- expect(featureHighlight.findHighestPriorityFeature()).toEqual('test-high-priority');
- });
- });
-
- describe('highlightFeatures', () => {
- it('calls setupFeatureHighlightPopover', () => {
- expect(featureHighlight.highlightFeatures()).toEqual('test');
- });
- });
-});
diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js b/spec/javascripts/filtered_search/dropdown_utils_spec.js
deleted file mode 100644
index 6eda4f391a4..00000000000
--- a/spec/javascripts/filtered_search/dropdown_utils_spec.js
+++ /dev/null
@@ -1,374 +0,0 @@
-import DropdownUtils from '~/filtered_search/dropdown_utils';
-import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
-import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
-import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
-
-describe('Dropdown Utils', () => {
- const issueListFixture = 'issues/issue_list.html';
- preloadFixtures(issueListFixture);
-
- describe('getEscapedText', () => {
- it('should return same word when it has no space', () => {
- const escaped = DropdownUtils.getEscapedText('textWithoutSpace');
-
- expect(escaped).toBe('textWithoutSpace');
- });
-
- it('should escape with double quotes', () => {
- let escaped = DropdownUtils.getEscapedText('text with space');
-
- expect(escaped).toBe('"text with space"');
-
- escaped = DropdownUtils.getEscapedText("won't fix");
-
- expect(escaped).toBe('"won\'t fix"');
- });
-
- it('should escape with single quotes', () => {
- const escaped = DropdownUtils.getEscapedText('won"t fix');
-
- expect(escaped).toBe("'won\"t fix'");
- });
-
- it('should escape with single quotes by default', () => {
- const escaped = DropdownUtils.getEscapedText('won"t\' fix');
-
- expect(escaped).toBe("'won\"t' fix'");
- });
- });
-
- describe('filterWithSymbol', () => {
- let input;
- const item = {
- title: '@root',
- };
-
- beforeEach(() => {
- setFixtures(`
- <input type="text" id="test" />
- `);
-
- input = document.getElementById('test');
- });
-
- it('should filter without symbol', () => {
- input.value = 'roo';
-
- const updatedItem = DropdownUtils.filterWithSymbol('@', input, item);
-
- expect(updatedItem.droplab_hidden).toBe(false);
- });
-
- it('should filter with symbol', () => {
- input.value = '@roo';
-
- const updatedItem = DropdownUtils.filterWithSymbol('@', input, item);
-
- expect(updatedItem.droplab_hidden).toBe(false);
- });
-
- describe('filters multiple word title', () => {
- const multipleWordItem = {
- title: 'Community Contributions',
- };
-
- it('should filter with double quote', () => {
- input.value = '"';
-
- const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
-
- expect(updatedItem.droplab_hidden).toBe(false);
- });
-
- it('should filter with double quote and symbol', () => {
- input.value = '~"';
-
- const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
-
- expect(updatedItem.droplab_hidden).toBe(false);
- });
-
- it('should filter with double quote and multiple words', () => {
- input.value = '"community con';
-
- const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
-
- expect(updatedItem.droplab_hidden).toBe(false);
- });
-
- it('should filter with double quote, symbol and multiple words', () => {
- input.value = '~"community con';
-
- const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
-
- expect(updatedItem.droplab_hidden).toBe(false);
- });
-
- it('should filter with single quote', () => {
- input.value = "'";
-
- const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
-
- expect(updatedItem.droplab_hidden).toBe(false);
- });
-
- it('should filter with single quote and symbol', () => {
- input.value = "~'";
-
- const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
-
- expect(updatedItem.droplab_hidden).toBe(false);
- });
-
- it('should filter with single quote and multiple words', () => {
- input.value = "'community con";
-
- const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
-
- expect(updatedItem.droplab_hidden).toBe(false);
- });
-
- it('should filter with single quote, symbol and multiple words', () => {
- input.value = "~'community con";
-
- const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
-
- expect(updatedItem.droplab_hidden).toBe(false);
- });
- });
- });
-
- describe('filterHint', () => {
- let input;
- let allowedKeys;
-
- beforeEach(() => {
- setFixtures(`
- <ul class="tokens-container">
- <li class="input-token">
- <input class="filtered-search" type="text" id="test" />
- </li>
- </ul>
- `);
-
- input = document.getElementById('test');
- allowedKeys = IssuableFilteredSearchTokenKeys.getKeys();
- });
-
- function config() {
- return {
- input,
- allowedKeys,
- };
- }
-
- it('should filter', () => {
- input.value = 'l';
- let updatedItem = DropdownUtils.filterHint(config(), {
- hint: 'label',
- });
-
- expect(updatedItem.droplab_hidden).toBe(false);
-
- input.value = 'o';
- updatedItem = DropdownUtils.filterHint(config(), {
- hint: 'label',
- });
-
- expect(updatedItem.droplab_hidden).toBe(true);
- });
-
- it('should return droplab_hidden false when item has no hint', () => {
- const updatedItem = DropdownUtils.filterHint(config(), {}, '');
-
- expect(updatedItem.droplab_hidden).toBe(false);
- });
-
- it('should allow multiple if item.type is array', () => {
- input.value = 'label:~first la';
- const updatedItem = DropdownUtils.filterHint(config(), {
- hint: 'label',
- type: 'array',
- });
-
- expect(updatedItem.droplab_hidden).toBe(false);
- });
-
- it('should prevent multiple if item.type is not array', () => {
- input.value = 'milestone:~first mile';
- let updatedItem = DropdownUtils.filterHint(config(), {
- hint: 'milestone',
- });
-
- expect(updatedItem.droplab_hidden).toBe(true);
-
- updatedItem = DropdownUtils.filterHint(config(), {
- hint: 'milestone',
- type: 'string',
- });
-
- expect(updatedItem.droplab_hidden).toBe(true);
- });
- });
-
- describe('setDataValueIfSelected', () => {
- beforeEach(() => {
- spyOn(FilteredSearchDropdownManager, 'addWordToInput').and.callFake(() => {});
- });
-
- it('calls addWordToInput when dataValue exists', () => {
- const selected = {
- getAttribute: () => 'value',
- hasAttribute: () => false,
- };
-
- DropdownUtils.setDataValueIfSelected(null, '=', selected);
-
- expect(FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1);
- });
-
- it('returns true when dataValue exists', () => {
- const selected = {
- getAttribute: () => 'value',
- hasAttribute: () => false,
- };
-
- const result = DropdownUtils.setDataValueIfSelected(null, '=', selected);
- const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected);
-
- expect(result).toBe(true);
- expect(result2).toBe(true);
- });
-
- it('returns false when dataValue does not exist', () => {
- const selected = {
- getAttribute: () => null,
- };
-
- const result = DropdownUtils.setDataValueIfSelected(null, '=', selected);
- const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected);
-
- expect(result).toBe(false);
- expect(result2).toBe(false);
- });
- });
-
- describe('getInputSelectionPosition', () => {
- describe('word with trailing spaces', () => {
- const value = 'label:none ';
-
- it('should return selectionStart when cursor is at the trailing space', () => {
- const { left, right } = DropdownUtils.getInputSelectionPosition({
- selectionStart: 11,
- value,
- });
-
- expect(left).toBe(11);
- expect(right).toBe(11);
- });
-
- it('should return input when cursor is at the start of input', () => {
- const { left, right } = DropdownUtils.getInputSelectionPosition({
- selectionStart: 0,
- value,
- });
-
- expect(left).toBe(0);
- expect(right).toBe(10);
- });
-
- it('should return input when cursor is at the middle of input', () => {
- const { left, right } = DropdownUtils.getInputSelectionPosition({
- selectionStart: 7,
- value,
- });
-
- expect(left).toBe(0);
- expect(right).toBe(10);
- });
-
- it('should return input when cursor is at the end of input', () => {
- const { left, right } = DropdownUtils.getInputSelectionPosition({
- selectionStart: 10,
- value,
- });
-
- expect(left).toBe(0);
- expect(right).toBe(10);
- });
- });
-
- describe('multiple words', () => {
- const value = 'label:~"Community Contribution"';
-
- it('should return input when cursor is after the first word', () => {
- const { left, right } = DropdownUtils.getInputSelectionPosition({
- selectionStart: 17,
- value,
- });
-
- expect(left).toBe(0);
- expect(right).toBe(31);
- });
-
- it('should return input when cursor is before the second word', () => {
- const { left, right } = DropdownUtils.getInputSelectionPosition({
- selectionStart: 18,
- value,
- });
-
- expect(left).toBe(0);
- expect(right).toBe(31);
- });
- });
-
- describe('incomplete multiple words', () => {
- const value = 'label:~"Community Contribution';
-
- it('should return entire input when cursor is at the start of input', () => {
- const { left, right } = DropdownUtils.getInputSelectionPosition({
- selectionStart: 0,
- value,
- });
-
- expect(left).toBe(0);
- expect(right).toBe(30);
- });
-
- it('should return entire input when cursor is at the end of input', () => {
- const { left, right } = DropdownUtils.getInputSelectionPosition({
- selectionStart: 30,
- value,
- });
-
- expect(left).toBe(0);
- expect(right).toBe(30);
- });
- });
- });
-
- describe('getSearchQuery', () => {
- let authorToken;
-
- beforeEach(() => {
- loadFixtures(issueListFixture);
-
- authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
- const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term');
-
- const tokensContainer = document.querySelector('.tokens-container');
- tokensContainer.appendChild(searchTermToken);
- tokensContainer.appendChild(authorToken);
- });
-
- it('uses original value if present', () => {
- const originalValue = 'original dance';
- const valueContainer = authorToken.querySelector('.value-container');
- valueContainer.dataset.originalValue = originalValue;
-
- const searchQuery = DropdownUtils.getSearchQuery();
-
- expect(searchQuery).toBe(' search term author:=original dance');
- });
- });
-});
diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js
deleted file mode 100644
index d0b54a16747..00000000000
--- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js
+++ /dev/null
@@ -1,580 +0,0 @@
-import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
-import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error';
-import RecentSearchesRoot from '~/filtered_search/recent_searches_root';
-import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
-import '~/lib/utils/common_utils';
-import DropdownUtils from '~/filtered_search/dropdown_utils';
-import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
-import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
-import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
-import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
-import { BACKSPACE_KEY_CODE, DELETE_KEY_CODE } from '~/lib/utils/keycodes';
-
-describe('Filtered Search Manager', function() {
- let input;
- let manager;
- let tokensContainer;
- const page = 'issues';
- const placeholder = 'Search or filter results...';
-
- function dispatchBackspaceEvent(element, eventType) {
- const event = new Event(eventType);
- event.keyCode = BACKSPACE_KEY_CODE;
- element.dispatchEvent(event);
- }
-
- function dispatchDeleteEvent(element, eventType) {
- const event = new Event(eventType);
- event.keyCode = DELETE_KEY_CODE;
- element.dispatchEvent(event);
- }
-
- function dispatchAltBackspaceEvent(element, eventType) {
- const event = new Event(eventType);
- event.altKey = true;
- event.keyCode = BACKSPACE_KEY_CODE;
- element.dispatchEvent(event);
- }
-
- function dispatchCtrlBackspaceEvent(element, eventType) {
- const event = new Event(eventType);
- event.ctrlKey = true;
- event.keyCode = BACKSPACE_KEY_CODE;
- element.dispatchEvent(event);
- }
-
- function dispatchMetaBackspaceEvent(element, eventType) {
- const event = new Event(eventType);
- event.metaKey = true;
- event.keyCode = BACKSPACE_KEY_CODE;
- element.dispatchEvent(event);
- }
-
- function getVisualTokens() {
- return tokensContainer.querySelectorAll('.js-visual-token');
- }
-
- beforeEach(() => {
- setFixtures(`
- <div class="filtered-search-box">
- <form>
- <ul class="tokens-container list-unstyled">
- ${FilteredSearchSpecHelper.createInputHTML(placeholder)}
- </ul>
- <button class="clear-search" type="button">
- <i class="fa fa-times"></i>
- </button>
- </form>
- </div>
- `);
-
- spyOn(FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {});
- });
-
- const initializeManager = () => {
- /* eslint-disable jasmine/no-unsafe-spy */
- spyOn(FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {});
- spyOn(FilteredSearchManager.prototype, 'tokenChange').and.callFake(() => {});
- spyOn(FilteredSearchDropdownManager.prototype, 'updateDropdownOffset').and.callFake(() => {});
- spyOn(gl.utils, 'getParameterByName').and.returnValue(null);
- spyOn(FilteredSearchVisualTokens, 'unselectTokens').and.callThrough();
- /* eslint-enable jasmine/no-unsafe-spy */
-
- input = document.querySelector('.filtered-search');
- tokensContainer = document.querySelector('.tokens-container');
- manager = new FilteredSearchManager({ page });
- manager.setup();
- };
-
- afterEach(() => {
- manager.cleanup();
- });
-
- describe('class constructor', () => {
- const isLocalStorageAvailable = 'isLocalStorageAvailable';
- let RecentSearchesStoreSpy;
-
- beforeEach(() => {
- spyOn(RecentSearchesService, 'isAvailable').and.returnValue(isLocalStorageAvailable);
- spyOn(RecentSearchesRoot.prototype, 'render');
- RecentSearchesStoreSpy = spyOnDependency(FilteredSearchManager, 'RecentSearchesStore');
- });
-
- it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => {
- manager = new FilteredSearchManager({ page });
-
- expect(RecentSearchesService.isAvailable).toHaveBeenCalled();
- expect(RecentSearchesStoreSpy).toHaveBeenCalledWith({
- isLocalStorageAvailable,
- allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
- });
- });
- });
-
- describe('setup', () => {
- beforeEach(() => {
- manager = new FilteredSearchManager({ page });
- });
-
- it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => {
- spyOn(RecentSearchesService.prototype, 'fetch').and.callFake(() =>
- Promise.reject(new RecentSearchesServiceError()),
- );
- spyOn(window, 'Flash');
-
- manager.setup();
-
- expect(window.Flash).not.toHaveBeenCalled();
- });
- });
-
- describe('searchState', () => {
- beforeEach(() => {
- spyOn(FilteredSearchManager.prototype, 'search').and.callFake(() => {});
- initializeManager();
- });
-
- it('should blur button', () => {
- const e = {
- preventDefault: () => {},
- currentTarget: {
- blur: () => {},
- },
- };
- spyOn(e.currentTarget, 'blur').and.callThrough();
- manager.searchState(e);
-
- expect(e.currentTarget.blur).toHaveBeenCalled();
- });
-
- it('should not call search if there is no state', () => {
- const e = {
- preventDefault: () => {},
- currentTarget: {
- blur: () => {},
- },
- };
-
- manager.searchState(e);
-
- expect(FilteredSearchManager.prototype.search).not.toHaveBeenCalled();
- });
-
- it('should call search when there is state', () => {
- const e = {
- preventDefault: () => {},
- currentTarget: {
- blur: () => {},
- dataset: {
- state: 'opened',
- },
- },
- };
-
- manager.searchState(e);
-
- expect(FilteredSearchManager.prototype.search).toHaveBeenCalledWith('opened');
- });
- });
-
- describe('search', () => {
- const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened';
-
- beforeEach(() => {
- initializeManager();
- });
-
- it('should search with a single word', done => {
- input.value = 'searchTerm';
-
- spyOnDependency(FilteredSearchManager, 'visitUrl').and.callFake(url => {
- expect(url).toEqual(`${defaultParams}&search=searchTerm`);
- done();
- });
-
- manager.search();
- });
-
- it('should search with multiple words', done => {
- input.value = 'awesome search terms';
-
- spyOnDependency(FilteredSearchManager, 'visitUrl').and.callFake(url => {
- expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`);
- done();
- });
-
- manager.search();
- });
-
- it('should search with special characters', done => {
- input.value = '~!@#$%^&*()_+{}:<>,.?/';
-
- spyOnDependency(FilteredSearchManager, 'visitUrl').and.callFake(url => {
- expect(url).toEqual(
- `${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`,
- );
- done();
- });
-
- manager.search();
- });
-
- it('removes duplicated tokens', done => {
- tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
- ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')}
- ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')}
- `);
-
- spyOnDependency(FilteredSearchManager, 'visitUrl').and.callFake(url => {
- expect(url).toEqual(`${defaultParams}&label_name[]=bug`);
- done();
- });
-
- manager.search();
- });
- });
-
- describe('handleInputPlaceholder', () => {
- beforeEach(() => {
- initializeManager();
- });
-
- it('should render placeholder when there is no input', () => {
- expect(input.placeholder).toEqual(placeholder);
- });
-
- it('should not render placeholder when there is input', () => {
- input.value = 'test words';
-
- const event = new Event('input');
- input.dispatchEvent(event);
-
- expect(input.placeholder).toEqual('');
- });
-
- it('should not render placeholder when there are tokens and no input', () => {
- tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
- FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
- );
-
- const event = new Event('input');
- input.dispatchEvent(event);
-
- expect(input.placeholder).toEqual('');
- });
- });
-
- describe('checkForBackspace', () => {
- beforeEach(() => {
- initializeManager();
- });
-
- describe('tokens and no input', () => {
- beforeEach(() => {
- tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
- FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
- );
- });
-
- it('removes last token', () => {
- spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
- dispatchBackspaceEvent(input, 'keyup');
- dispatchBackspaceEvent(input, 'keyup');
-
- expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
- });
-
- it('sets the input', () => {
- spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial').and.callThrough();
- dispatchDeleteEvent(input, 'keyup');
- dispatchDeleteEvent(input, 'keyup');
-
- expect(FilteredSearchVisualTokens.getLastTokenPartial).toHaveBeenCalled();
- expect(input.value).toEqual('~bug');
- });
- });
-
- it('does not remove token or change input when there is existing input', () => {
- spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
- spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial').and.callThrough();
-
- input.value = 'text';
- dispatchDeleteEvent(input, 'keyup');
-
- expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
- expect(FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled();
- expect(input.value).toEqual('text');
- });
-
- it('does not remove previous token on single backspace press', () => {
- spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
- spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial').and.callThrough();
-
- input.value = 't';
- dispatchDeleteEvent(input, 'keyup');
-
- expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
- expect(FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled();
- expect(input.value).toEqual('t');
- });
- });
-
- describe('checkForAltOrCtrlBackspace', () => {
- beforeEach(() => {
- initializeManager();
- spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
- });
-
- describe('tokens and no input', () => {
- beforeEach(() => {
- tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
- FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
- );
- });
-
- it('removes last token via alt-backspace', () => {
- dispatchAltBackspaceEvent(input, 'keydown');
-
- expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
- });
-
- it('removes last token via ctrl-backspace', () => {
- dispatchCtrlBackspaceEvent(input, 'keydown');
-
- expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
- });
- });
-
- describe('tokens and input', () => {
- beforeEach(() => {
- tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
- FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
- );
- });
-
- it('does not remove token or change input via alt-backspace when there is existing input', () => {
- input = manager.filteredSearchInput;
- input.value = 'text';
- dispatchAltBackspaceEvent(input, 'keydown');
-
- expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
- expect(input.value).toEqual('text');
- });
-
- it('does not remove token or change input via ctrl-backspace when there is existing input', () => {
- input = manager.filteredSearchInput;
- input.value = 'text';
- dispatchCtrlBackspaceEvent(input, 'keydown');
-
- expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
- expect(input.value).toEqual('text');
- });
- });
- });
-
- describe('checkForMetaBackspace', () => {
- beforeEach(() => {
- initializeManager();
- });
-
- beforeEach(() => {
- tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
- FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
- );
- });
-
- it('removes all tokens and input', () => {
- spyOn(FilteredSearchManager.prototype, 'clearSearch').and.callThrough();
- dispatchMetaBackspaceEvent(input, 'keydown');
-
- expect(manager.clearSearch).toHaveBeenCalled();
- expect(manager.filteredSearchInput.value).toEqual('');
- expect(DropdownUtils.getSearchQuery()).toEqual('');
- });
- });
-
- describe('removeToken', () => {
- beforeEach(() => {
- initializeManager();
- });
-
- it('removes token even when it is already selected', () => {
- tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
- FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
- );
-
- tokensContainer.querySelector('.js-visual-token .remove-token').click();
-
- expect(tokensContainer.querySelector('.js-visual-token')).toEqual(null);
- });
-
- describe('unselected token', () => {
- beforeEach(() => {
- spyOn(FilteredSearchManager.prototype, 'removeSelectedToken').and.callThrough();
-
- tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
- FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'),
- );
- tokensContainer.querySelector('.js-visual-token .remove-token').click();
- });
-
- it('removes token when remove button is selected', () => {
- expect(tokensContainer.querySelector('.js-visual-token')).toEqual(null);
- });
-
- it('calls removeSelectedToken', () => {
- expect(manager.removeSelectedToken).toHaveBeenCalled();
- });
- });
- });
-
- describe('removeSelectedTokenKeydown', () => {
- beforeEach(() => {
- initializeManager();
- tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
- FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
- );
- });
-
- it('removes selected token when the backspace key is pressed', () => {
- expect(getVisualTokens().length).toEqual(1);
-
- dispatchBackspaceEvent(document, 'keydown');
-
- expect(getVisualTokens().length).toEqual(0);
- });
-
- it('removes selected token when the delete key is pressed', () => {
- expect(getVisualTokens().length).toEqual(1);
-
- dispatchDeleteEvent(document, 'keydown');
-
- expect(getVisualTokens().length).toEqual(0);
- });
-
- it('updates the input placeholder after removal', () => {
- manager.handleInputPlaceholder();
-
- expect(input.placeholder).toEqual('');
- expect(getVisualTokens().length).toEqual(1);
-
- dispatchBackspaceEvent(document, 'keydown');
-
- expect(input.placeholder).not.toEqual('');
- expect(getVisualTokens().length).toEqual(0);
- });
-
- it('updates the clear button after removal', () => {
- manager.toggleClearSearchButton();
-
- const clearButton = document.querySelector('.clear-search');
-
- expect(clearButton.classList.contains('hidden')).toEqual(false);
- expect(getVisualTokens().length).toEqual(1);
-
- dispatchBackspaceEvent(document, 'keydown');
-
- expect(clearButton.classList.contains('hidden')).toEqual(true);
- expect(getVisualTokens().length).toEqual(0);
- });
- });
-
- describe('removeSelectedToken', () => {
- beforeEach(() => {
- spyOn(FilteredSearchVisualTokens, 'removeSelectedToken').and.callThrough();
- spyOn(FilteredSearchManager.prototype, 'handleInputPlaceholder').and.callThrough();
- spyOn(FilteredSearchManager.prototype, 'toggleClearSearchButton').and.callThrough();
- initializeManager();
- });
-
- it('calls FilteredSearchVisualTokens.removeSelectedToken', () => {
- manager.removeSelectedToken();
-
- expect(FilteredSearchVisualTokens.removeSelectedToken).toHaveBeenCalled();
- });
-
- it('calls handleInputPlaceholder', () => {
- manager.removeSelectedToken();
-
- expect(manager.handleInputPlaceholder).toHaveBeenCalled();
- });
-
- it('calls toggleClearSearchButton', () => {
- manager.removeSelectedToken();
-
- expect(manager.toggleClearSearchButton).toHaveBeenCalled();
- });
-
- it('calls update dropdown offset', () => {
- manager.removeSelectedToken();
-
- expect(manager.dropdownManager.updateDropdownOffset).toHaveBeenCalled();
- });
- });
-
- describe('Clearing search', () => {
- beforeEach(() => {
- initializeManager();
- });
-
- it('Clicking the "x" clear button, clears the input', () => {
- const inputValue = 'label:=~bug';
- manager.filteredSearchInput.value = inputValue;
- manager.filteredSearchInput.dispatchEvent(new Event('input'));
-
- expect(DropdownUtils.getSearchQuery()).toEqual(inputValue);
-
- manager.clearSearchButton.click();
-
- expect(manager.filteredSearchInput.value).toEqual('');
- expect(DropdownUtils.getSearchQuery()).toEqual('');
- });
- });
-
- describe('toggleInputContainerFocus', () => {
- beforeEach(() => {
- initializeManager();
- });
-
- it('toggles on focus', () => {
- input.focus();
-
- expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(
- true,
- );
- });
-
- it('toggles on blur', () => {
- input.blur();
-
- expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(
- false,
- );
- });
- });
-
- describe('getAllParams', () => {
- beforeEach(() => {
- this.paramsArr = ['key=value', 'otherkey=othervalue'];
-
- initializeManager();
- });
-
- it('correctly modifies params when custom modifier is passed', () => {
- const modifedParams = manager.getAllParams.call(
- {
- modifyUrlParams: paramsArr => paramsArr.reverse(),
- },
- [].concat(this.paramsArr),
- );
-
- expect(modifedParams[0]).toBe(this.paramsArr[1]);
- });
-
- it('does not modify params when no custom modifier is passed', () => {
- const modifedParams = manager.getAllParams.call({}, this.paramsArr);
-
- expect(modifedParams[1]).toBe(this.paramsArr[1]);
- });
- });
-});
diff --git a/spec/javascripts/filtered_search/recent_searches_root_spec.js b/spec/javascripts/filtered_search/recent_searches_root_spec.js
deleted file mode 100644
index 70dd4e9570d..00000000000
--- a/spec/javascripts/filtered_search/recent_searches_root_spec.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import RecentSearchesRoot from '~/filtered_search/recent_searches_root';
-
-describe('RecentSearchesRoot', () => {
- describe('render', () => {
- let recentSearchesRoot;
- let data;
- let template;
- let VueSpy;
-
- beforeEach(() => {
- recentSearchesRoot = {
- store: {
- state: 'state',
- },
- };
-
- VueSpy = spyOnDependency(RecentSearchesRoot, 'Vue').and.callFake(options => {
- ({ data, template } = options);
- });
-
- RecentSearchesRoot.prototype.render.call(recentSearchesRoot);
- });
-
- it('should instantiate Vue', () => {
- expect(VueSpy).toHaveBeenCalled();
- expect(data()).toBe(recentSearchesRoot.store.state);
- expect(template).toContain(':is-local-storage-available="isLocalStorageAvailable"');
- });
- });
-});
diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js
deleted file mode 100644
index 188f83eca16..00000000000
--- a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js
+++ /dev/null
@@ -1,158 +0,0 @@
-import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
-import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error';
-import AccessorUtilities from '~/lib/utils/accessor';
-
-describe('RecentSearchesService', () => {
- let service;
-
- beforeEach(() => {
- service = new RecentSearchesService();
- window.localStorage.removeItem(service.localStorageKey);
- });
-
- describe('fetch', () => {
- beforeEach(() => {
- spyOn(RecentSearchesService, 'isAvailable').and.returnValue(true);
- });
-
- it('should default to empty array', done => {
- const fetchItemsPromise = service.fetch();
-
- fetchItemsPromise
- .then(items => {
- expect(items).toEqual([]);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('should reject when unable to parse', done => {
- window.localStorage.setItem(service.localStorageKey, 'fail');
- const fetchItemsPromise = service.fetch();
-
- fetchItemsPromise
- .then(done.fail)
- .catch(error => {
- expect(error).toEqual(jasmine.any(SyntaxError));
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('should reject when service is unavailable', done => {
- RecentSearchesService.isAvailable.and.returnValue(false);
-
- service
- .fetch()
- .then(done.fail)
- .catch(error => {
- expect(error).toEqual(jasmine.any(Error));
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('should return items from localStorage', done => {
- window.localStorage.setItem(service.localStorageKey, '["foo", "bar"]');
- const fetchItemsPromise = service.fetch();
-
- fetchItemsPromise
- .then(items => {
- expect(items).toEqual(['foo', 'bar']);
- })
- .then(done)
- .catch(done.fail);
- });
-
- describe('if .isAvailable returns `false`', () => {
- beforeEach(() => {
- RecentSearchesService.isAvailable.and.returnValue(false);
-
- spyOn(window.localStorage, 'getItem');
- });
-
- it('should not call .getItem', done => {
- RecentSearchesService.prototype
- .fetch()
- .then(done.fail)
- .catch(err => {
- expect(err).toEqual(new RecentSearchesServiceError());
- expect(window.localStorage.getItem).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
- });
- });
-
- describe('setRecentSearches', () => {
- beforeEach(() => {
- spyOn(RecentSearchesService, 'isAvailable').and.returnValue(true);
- });
-
- it('should save things in localStorage', () => {
- const items = ['foo', 'bar'];
- service.save(items);
- const newLocalStorageValue = window.localStorage.getItem(service.localStorageKey);
-
- expect(JSON.parse(newLocalStorageValue)).toEqual(items);
- });
- });
-
- describe('save', () => {
- beforeEach(() => {
- spyOn(window.localStorage, 'setItem');
- spyOn(RecentSearchesService, 'isAvailable');
- });
-
- describe('if .isAvailable returns `true`', () => {
- const searchesString = 'searchesString';
- const localStorageKey = 'localStorageKey';
- const recentSearchesService = {
- localStorageKey,
- };
-
- beforeEach(() => {
- RecentSearchesService.isAvailable.and.returnValue(true);
-
- spyOn(JSON, 'stringify').and.returnValue(searchesString);
- });
-
- it('should call .setItem', () => {
- RecentSearchesService.prototype.save.call(recentSearchesService);
-
- expect(window.localStorage.setItem).toHaveBeenCalledWith(localStorageKey, searchesString);
- });
- });
-
- describe('if .isAvailable returns `false`', () => {
- beforeEach(() => {
- RecentSearchesService.isAvailable.and.returnValue(false);
- });
-
- it('should not call .setItem', () => {
- RecentSearchesService.prototype.save();
-
- expect(window.localStorage.setItem).not.toHaveBeenCalled();
- });
- });
- });
-
- describe('isAvailable', () => {
- let isAvailable;
-
- beforeEach(() => {
- spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.callThrough();
-
- isAvailable = RecentSearchesService.isAvailable();
- });
-
- it('should call .isLocalStorageAccessSafe', () => {
- expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
- });
-
- it('should return a boolean', () => {
- expect(typeof isAvailable).toBe('boolean');
- });
- });
-});
diff --git a/spec/javascripts/filtered_search/visual_token_value_spec.js b/spec/javascripts/filtered_search/visual_token_value_spec.js
deleted file mode 100644
index 4469ade1874..00000000000
--- a/spec/javascripts/filtered_search/visual_token_value_spec.js
+++ /dev/null
@@ -1,389 +0,0 @@
-import { escape as esc } from 'lodash';
-import VisualTokenValue from '~/filtered_search/visual_token_value';
-import AjaxCache from '~/lib/utils/ajax_cache';
-import UsersCache from '~/lib/utils/users_cache';
-import DropdownUtils from '~/filtered_search//dropdown_utils';
-import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
-
-describe('Filtered Search Visual Tokens', () => {
- const findElements = tokenElement => {
- const tokenNameElement = tokenElement.querySelector('.name');
- const tokenValueContainer = tokenElement.querySelector('.value-container');
- const tokenValueElement = tokenValueContainer.querySelector('.value');
- const tokenOperatorElement = tokenElement.querySelector('.operator');
- const tokenType = tokenNameElement.innerText.toLowerCase();
- const tokenValue = tokenValueElement.innerText;
- const tokenOperator = tokenOperatorElement.innerText;
- const subject = new VisualTokenValue(tokenValue, tokenType, tokenOperator);
- return { subject, tokenValueContainer, tokenValueElement };
- };
-
- let tokensContainer;
- let authorToken;
- let bugLabelToken;
-
- beforeEach(() => {
- setFixtures(`
- <ul class="tokens-container">
- ${FilteredSearchSpecHelper.createInputHTML()}
- </ul>
- `);
- tokensContainer = document.querySelector('.tokens-container');
-
- authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
- bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug');
- });
-
- describe('updateUserTokenAppearance', () => {
- let usersCacheSpy;
-
- beforeEach(() => {
- spyOn(UsersCache, 'retrieve').and.callFake(username => usersCacheSpy(username));
- });
-
- it('ignores error if UsersCache throws', done => {
- spyOn(window, 'Flash');
- const dummyError = new Error('Earth rotated backwards');
- const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
- const tokenValue = tokenValueElement.innerText;
- usersCacheSpy = username => {
- expect(`@${username}`).toBe(tokenValue);
- return Promise.reject(dummyError);
- };
-
- subject
- .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
- .then(() => {
- expect(window.Flash.calls.count()).toBe(0);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('does nothing if user cannot be found', done => {
- const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
- const tokenValue = tokenValueElement.innerText;
- usersCacheSpy = username => {
- expect(`@${username}`).toBe(tokenValue);
- return Promise.resolve(undefined);
- };
-
- subject
- .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
- .then(() => {
- expect(tokenValueElement.innerText).toBe(tokenValue);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('replaces author token with avatar and display name', done => {
- const dummyUser = {
- name: 'Important Person',
- avatar_url: 'https://host.invalid/mypics/avatar.png',
- };
- const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
- const tokenValue = tokenValueElement.innerText;
- usersCacheSpy = username => {
- expect(`@${username}`).toBe(tokenValue);
- return Promise.resolve(dummyUser);
- };
-
- subject
- .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
- .then(() => {
- expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue);
- expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
- const avatar = tokenValueElement.querySelector('img.avatar');
-
- expect(avatar.src).toBe(dummyUser.avatar_url);
- expect(avatar.alt).toBe('');
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('escapes user name when creating token', done => {
- const dummyUser = {
- name: '<script>',
- avatar_url: `${gl.TEST_HOST}/mypics/avatar.png`,
- };
- const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
- const tokenValue = tokenValueElement.innerText;
- usersCacheSpy = username => {
- expect(`@${username}`).toBe(tokenValue);
- return Promise.resolve(dummyUser);
- };
-
- subject
- .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
- .then(() => {
- expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
- tokenValueElement.querySelector('.avatar').remove();
-
- expect(tokenValueElement.innerHTML.trim()).toBe(esc(dummyUser.name));
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
- describe('updateLabelTokenColor', () => {
- const jsonFixtureName = 'labels/project_labels.json';
- const dummyEndpoint = '/dummy/endpoint';
-
- preloadFixtures(jsonFixtureName);
-
- let labelData;
-
- beforeAll(() => {
- labelData = getJSONFixture(jsonFixtureName);
- });
-
- const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
- 'label',
- '=',
- '~doesnotexist',
- );
- const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
- 'label',
- '=',
- '~"some space"',
- );
-
- beforeEach(() => {
- tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
- ${bugLabelToken.outerHTML}
- ${missingLabelToken.outerHTML}
- ${spaceLabelToken.outerHTML}
- `);
-
- const filteredSearchInput = document.querySelector('.filtered-search');
- filteredSearchInput.dataset.runnerTagsEndpoint = `${dummyEndpoint}/admin/runners/tag_list`;
- filteredSearchInput.dataset.labelsEndpoint = `${dummyEndpoint}/-/labels`;
- filteredSearchInput.dataset.milestonesEndpoint = `${dummyEndpoint}/-/milestones`;
-
- AjaxCache.internalStorage = {};
- AjaxCache.internalStorage[`${filteredSearchInput.dataset.labelsEndpoint}.json`] = labelData;
- });
-
- const parseColor = color => {
- const dummyElement = document.createElement('div');
- dummyElement.style.color = color;
- return dummyElement.style.color;
- };
-
- const expectValueContainerStyle = (tokenValueContainer, label) => {
- expect(tokenValueContainer.getAttribute('style')).not.toBe(null);
- expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color));
- expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color));
- };
-
- const findLabel = tokenValue =>
- labelData.find(label => tokenValue === `~${DropdownUtils.getEscapedText(label.title)}`);
-
- it('updates the color of a label token', done => {
- const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
- const tokenValue = tokenValueElement.innerText;
- const matchingLabel = findLabel(tokenValue);
-
- subject
- .updateLabelTokenColor(tokenValueContainer, tokenValue)
- .then(() => {
- expectValueContainerStyle(tokenValueContainer, matchingLabel);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('updates the color of a label token with spaces', done => {
- const { subject, tokenValueContainer, tokenValueElement } = findElements(spaceLabelToken);
- const tokenValue = tokenValueElement.innerText;
- const matchingLabel = findLabel(tokenValue);
-
- subject
- .updateLabelTokenColor(tokenValueContainer, tokenValue)
- .then(() => {
- expectValueContainerStyle(tokenValueContainer, matchingLabel);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('does not change color of a missing label', done => {
- const { subject, tokenValueContainer, tokenValueElement } = findElements(missingLabelToken);
- const tokenValue = tokenValueElement.innerText;
- const matchingLabel = findLabel(tokenValue);
-
- expect(matchingLabel).toBe(undefined);
-
- subject
- .updateLabelTokenColor(tokenValueContainer, tokenValue)
- .then(() => {
- expect(tokenValueContainer.getAttribute('style')).toBe(null);
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
- describe('setTokenStyle', () => {
- let originalTextColor;
-
- beforeEach(() => {
- originalTextColor = bugLabelToken.style.color;
- });
-
- it('should set backgroundColor', () => {
- const originalBackgroundColor = bugLabelToken.style.backgroundColor;
- const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'blue', 'white');
-
- expect(token.style.backgroundColor).toEqual('blue');
- expect(token.style.backgroundColor).not.toEqual(originalBackgroundColor);
- });
-
- it('should set textColor', () => {
- const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'white', 'black');
-
- expect(token.style.color).toEqual('black');
- expect(token.style.color).not.toEqual(originalTextColor);
- });
-
- it('should add inverted class when textColor is #FFFFFF', () => {
- const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'black', '#FFFFFF');
-
- expect(token.style.color).toEqual('rgb(255, 255, 255)');
- expect(token.style.color).not.toEqual(originalTextColor);
- expect(token.querySelector('.remove-token').classList.contains('inverted')).toEqual(true);
- });
- });
-
- describe('render', () => {
- const setupSpies = subject => {
- spyOn(subject, 'updateLabelTokenColor'); // eslint-disable-line jasmine/no-unsafe-spy
- const updateLabelTokenColorSpy = subject.updateLabelTokenColor;
-
- spyOn(subject, 'updateUserTokenAppearance'); // eslint-disable-line jasmine/no-unsafe-spy
- const updateUserTokenAppearanceSpy = subject.updateUserTokenAppearance;
-
- return { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy };
- };
-
- const keywordToken = FilteredSearchSpecHelper.createFilterVisualToken('search');
- const milestoneToken = FilteredSearchSpecHelper.createFilterVisualToken(
- 'milestone',
- 'upcoming',
- );
-
- beforeEach(() => {
- tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
- ${authorToken.outerHTML}
- ${bugLabelToken.outerHTML}
- ${keywordToken.outerHTML}
- ${milestoneToken.outerHTML}
- `);
- });
-
- it('renders a author token value element', () => {
- const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
-
- const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject);
- subject.render(tokenValueContainer, tokenValueElement);
-
- expect(updateUserTokenAppearanceSpy.calls.count()).toBe(1);
- const expectedArgs = [tokenValueContainer, tokenValueElement];
-
- expect(updateUserTokenAppearanceSpy.calls.argsFor(0)).toEqual(expectedArgs);
- expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
- });
-
- it('renders a label token value element', () => {
- const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
-
- const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject);
- subject.render(tokenValueContainer, tokenValueElement);
-
- expect(updateLabelTokenColorSpy.calls.count()).toBe(1);
- const expectedArgs = [tokenValueContainer];
-
- expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs);
- expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
- });
-
- it('renders a milestone token value element', () => {
- const { subject, tokenValueContainer, tokenValueElement } = findElements(milestoneToken);
-
- const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject);
- subject.render(tokenValueContainer, tokenValueElement);
-
- expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
- expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
- });
-
- it('does not update user token appearance for `none` filter', () => {
- const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
-
- subject.tokenValue = 'none';
-
- const { updateUserTokenAppearanceSpy } = setupSpies(subject);
- subject.render(tokenValueContainer, tokenValueElement);
-
- expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
- });
-
- it('does not update user token appearance for `None` filter', () => {
- const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
-
- subject.tokenValue = 'None';
-
- const { updateUserTokenAppearanceSpy } = setupSpies(subject);
- subject.render(tokenValueContainer, tokenValueElement);
-
- expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
- });
-
- it('does not update user token appearance for `any` filter', () => {
- const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
-
- subject.tokenValue = 'any';
-
- const { updateUserTokenAppearanceSpy } = setupSpies(subject);
- subject.render(tokenValueContainer, tokenValueElement);
-
- expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
- });
-
- it('does not update label token color for `None` filter', () => {
- const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
-
- subject.tokenValue = 'None';
-
- const { updateLabelTokenColorSpy } = setupSpies(subject);
- subject.render(tokenValueContainer, tokenValueElement);
-
- expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
- });
-
- it('does not update label token color for `none` filter', () => {
- const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
-
- subject.tokenValue = 'none';
-
- const { updateLabelTokenColorSpy } = setupSpies(subject);
- subject.render(tokenValueContainer, tokenValueElement);
-
- expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
- });
-
- it('does not update label token color for `any` filter', () => {
- const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
-
- subject.tokenValue = 'any';
-
- const { updateLabelTokenColorSpy } = setupSpies(subject);
- subject.render(tokenValueContainer, tokenValueElement);
-
- expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
- });
- });
-});
diff --git a/spec/javascripts/flash_spec.js b/spec/javascripts/flash_spec.js
deleted file mode 100644
index 39ca4eedb69..00000000000
--- a/spec/javascripts/flash_spec.js
+++ /dev/null
@@ -1,236 +0,0 @@
-import flash, { createFlashEl, createAction, hideFlash, removeFlashClickListener } from '~/flash';
-
-describe('Flash', () => {
- describe('createFlashEl', () => {
- let el;
-
- beforeEach(() => {
- el = document.createElement('div');
- });
-
- afterEach(() => {
- el.innerHTML = '';
- });
-
- it('creates flash element with type', () => {
- el.innerHTML = createFlashEl('testing', 'alert');
-
- expect(el.querySelector('.flash-alert')).not.toBeNull();
- });
-
- it('escapes text', () => {
- el.innerHTML = createFlashEl('<script>alert("a");</script>', 'alert');
-
- expect(el.querySelector('.flash-text').textContent.trim()).toBe(
- '<script>alert("a");</script>',
- );
- });
- });
-
- describe('hideFlash', () => {
- let el;
-
- beforeEach(() => {
- el = document.createElement('div');
- el.className = 'js-testing';
- });
-
- it('sets transition style', () => {
- hideFlash(el);
-
- expect(el.style['transition-property']).toBe('opacity');
-
- expect(el.style['transition-duration']).toBe('0.15s');
- });
-
- it('sets opacity style', () => {
- hideFlash(el);
-
- expect(el.style.opacity).toBe('0');
- });
-
- it('does not set styles when fadeTransition is false', () => {
- hideFlash(el, false);
-
- expect(el.style.opacity).toBe('');
-
- expect(el.style.transition).toBe('');
- });
-
- it('removes element after transitionend', () => {
- document.body.appendChild(el);
-
- hideFlash(el);
- el.dispatchEvent(new Event('transitionend'));
-
- expect(document.querySelector('.js-testing')).toBeNull();
- });
-
- it('calls event listener callback once', () => {
- spyOn(el, 'remove').and.callThrough();
- document.body.appendChild(el);
-
- hideFlash(el);
-
- el.dispatchEvent(new Event('transitionend'));
- el.dispatchEvent(new Event('transitionend'));
-
- expect(el.remove.calls.count()).toBe(1);
- });
- });
-
- describe('createAction', () => {
- let el;
-
- beforeEach(() => {
- el = document.createElement('div');
- });
-
- it('creates link with href', () => {
- el.innerHTML = createAction({
- href: 'testing',
- title: 'test',
- });
-
- expect(el.querySelector('.flash-action').href).toContain('testing');
- });
-
- it('uses hash as href when no href is present', () => {
- el.innerHTML = createAction({
- title: 'test',
- });
-
- expect(el.querySelector('.flash-action').href).toContain('#');
- });
-
- it('adds role when no href is present', () => {
- el.innerHTML = createAction({
- title: 'test',
- });
-
- expect(el.querySelector('.flash-action').getAttribute('role')).toBe('button');
- });
-
- it('escapes the title text', () => {
- el.innerHTML = createAction({
- title: '<script>alert("a")</script>',
- });
-
- expect(el.querySelector('.flash-action').textContent.trim()).toBe(
- '<script>alert("a")</script>',
- );
- });
- });
-
- describe('createFlash', () => {
- describe('no flash-container', () => {
- it('does not add to the DOM', () => {
- const flashEl = flash('testing');
-
- expect(flashEl).toBeNull();
-
- expect(document.querySelector('.flash-alert')).toBeNull();
- });
- });
-
- describe('with flash-container', () => {
- beforeEach(() => {
- document.body.innerHTML += `
- <div class="content-wrapper js-content-wrapper">
- <div class="flash-container"></div>
- </div>
- `;
- });
-
- afterEach(() => {
- document.querySelector('.js-content-wrapper').remove();
- });
-
- it('adds flash element into container', () => {
- flash('test', 'alert', document, null, false, true);
-
- expect(document.querySelector('.flash-alert')).not.toBeNull();
-
- expect(document.body.className).toContain('flash-shown');
- });
-
- it('adds flash into specified parent', () => {
- flash('test', 'alert', document.querySelector('.content-wrapper'));
-
- expect(document.querySelector('.content-wrapper .flash-alert')).not.toBeNull();
- });
-
- it('adds container classes when inside content-wrapper', () => {
- flash('test');
-
- expect(document.querySelector('.flash-text').className).toBe('flash-text');
- });
-
- it('does not add container when outside of content-wrapper', () => {
- document.querySelector('.content-wrapper').className = 'js-content-wrapper';
- flash('test');
-
- expect(document.querySelector('.flash-text').className.trim()).toContain('flash-text');
- });
-
- it('removes element after clicking', () => {
- flash('test', 'alert', document, null, false, true);
-
- document.querySelector('.flash-alert .js-close-icon').click();
-
- expect(document.querySelector('.flash-alert')).toBeNull();
-
- expect(document.body.className).not.toContain('flash-shown');
- });
-
- describe('with actionConfig', () => {
- it('adds action link', () => {
- flash('test', 'alert', document, {
- title: 'test',
- });
-
- expect(document.querySelector('.flash-action')).not.toBeNull();
- });
-
- it('calls actionConfig clickHandler on click', () => {
- const actionConfig = {
- title: 'test',
- clickHandler: jasmine.createSpy('actionConfig'),
- };
-
- flash('test', 'alert', document, actionConfig);
-
- document.querySelector('.flash-action').click();
-
- expect(actionConfig.clickHandler).toHaveBeenCalled();
- });
- });
- });
- });
-
- describe('removeFlashClickListener', () => {
- beforeEach(() => {
- document.body.innerHTML += `
- <div class="flash-container">
- <div class="flash">
- <div class="close-icon js-close-icon"></div>
- </div>
- </div>
- `;
- });
-
- it('removes global flash on click', done => {
- const flashEl = document.querySelector('.flash');
-
- removeFlashClickListener(flashEl, false);
-
- flashEl.querySelector('.js-close-icon').click();
-
- setTimeout(() => {
- expect(document.querySelector('.flash')).toBeNull();
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/frequent_items/components/app_spec.js b/spec/javascripts/frequent_items/components/app_spec.js
deleted file mode 100644
index b293ed541fd..00000000000
--- a/spec/javascripts/frequent_items/components/app_spec.js
+++ /dev/null
@@ -1,257 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import Vue from 'vue';
-import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import axios from '~/lib/utils/axios_utils';
-import appComponent from '~/frequent_items/components/app.vue';
-import eventHub from '~/frequent_items/event_hub';
-import store from '~/frequent_items/store';
-import { FREQUENT_ITEMS, HOUR_IN_MS } from '~/frequent_items/constants';
-import { getTopFrequentItems } from '~/frequent_items/utils';
-import { currentSession, mockFrequentProjects, mockSearchedProjects } from '../mock_data';
-
-let session;
-const createComponentWithStore = (namespace = 'projects') => {
- session = currentSession[namespace];
- gon.api_version = session.apiVersion;
- const Component = Vue.extend(appComponent);
-
- return mountComponentWithStore(Component, {
- store,
- props: {
- namespace,
- currentUserName: session.username,
- currentItem: session.project || session.group,
- },
- });
-};
-
-describe('Frequent Items App Component', () => {
- let vm;
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- vm = createComponentWithStore();
- });
-
- afterEach(() => {
- mock.restore();
- vm.$destroy();
- });
-
- describe('methods', () => {
- describe('dropdownOpenHandler', () => {
- it('should fetch frequent items when no search has been previously made on desktop', () => {
- spyOn(vm, 'fetchFrequentItems');
-
- vm.dropdownOpenHandler();
-
- expect(vm.fetchFrequentItems).toHaveBeenCalledWith();
- });
- });
-
- describe('logItemAccess', () => {
- let storage;
-
- beforeEach(() => {
- storage = {};
-
- spyOn(window.localStorage, 'setItem').and.callFake((storageKey, value) => {
- storage[storageKey] = value;
- });
-
- spyOn(window.localStorage, 'getItem').and.callFake(storageKey => {
- if (storage[storageKey]) {
- return storage[storageKey];
- }
-
- return null;
- });
- });
-
- it('should create a project store if it does not exist and adds a project', () => {
- vm.logItemAccess(session.storageKey, session.project);
-
- const projects = JSON.parse(storage[session.storageKey]);
-
- expect(projects.length).toBe(1);
- expect(projects[0].frequency).toBe(1);
- expect(projects[0].lastAccessedOn).toBeDefined();
- });
-
- it('should prevent inserting same report multiple times into store', () => {
- vm.logItemAccess(session.storageKey, session.project);
- vm.logItemAccess(session.storageKey, session.project);
-
- const projects = JSON.parse(storage[session.storageKey]);
-
- expect(projects.length).toBe(1);
- });
-
- it('should increase frequency of report if it was logged multiple times over the course of an hour', () => {
- let projects;
- const newTimestamp = Date.now() + HOUR_IN_MS + 1;
-
- vm.logItemAccess(session.storageKey, session.project);
- projects = JSON.parse(storage[session.storageKey]);
-
- expect(projects[0].frequency).toBe(1);
-
- vm.logItemAccess(session.storageKey, {
- ...session.project,
- lastAccessedOn: newTimestamp,
- });
- projects = JSON.parse(storage[session.storageKey]);
-
- expect(projects[0].frequency).toBe(2);
- expect(projects[0].lastAccessedOn).not.toBe(session.project.lastAccessedOn);
- });
-
- it('should always update project metadata', () => {
- let projects;
- const oldProject = {
- ...session.project,
- };
-
- const newProject = {
- ...session.project,
- name: 'New Name',
- avatarUrl: 'new/avatar.png',
- namespace: 'New / Namespace',
- webUrl: 'http://localhost/new/web/url',
- };
-
- vm.logItemAccess(session.storageKey, oldProject);
- projects = JSON.parse(storage[session.storageKey]);
-
- expect(projects[0].name).toBe(oldProject.name);
- expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl);
- expect(projects[0].namespace).toBe(oldProject.namespace);
- expect(projects[0].webUrl).toBe(oldProject.webUrl);
-
- vm.logItemAccess(session.storageKey, newProject);
- projects = JSON.parse(storage[session.storageKey]);
-
- expect(projects[0].name).toBe(newProject.name);
- expect(projects[0].avatarUrl).toBe(newProject.avatarUrl);
- expect(projects[0].namespace).toBe(newProject.namespace);
- expect(projects[0].webUrl).toBe(newProject.webUrl);
- });
-
- it('should not add more than 20 projects in store', () => {
- for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT; id += 1) {
- const project = {
- ...session.project,
- id,
- };
- vm.logItemAccess(session.storageKey, project);
- }
-
- const projects = JSON.parse(storage[session.storageKey]);
-
- expect(projects.length).toBe(FREQUENT_ITEMS.MAX_COUNT);
- });
- });
- });
-
- describe('created', () => {
- it('should bind event listeners on eventHub', done => {
- spyOn(eventHub, '$on');
-
- createComponentWithStore().$mount();
-
- Vue.nextTick(() => {
- expect(eventHub.$on).toHaveBeenCalledWith('projects-dropdownOpen', jasmine.any(Function));
- done();
- });
- });
- });
-
- describe('beforeDestroy', () => {
- it('should unbind event listeners on eventHub', done => {
- spyOn(eventHub, '$off');
-
- vm.$mount();
- vm.$destroy();
-
- Vue.nextTick(() => {
- expect(eventHub.$off).toHaveBeenCalledWith('projects-dropdownOpen', jasmine.any(Function));
- done();
- });
- });
- });
-
- describe('template', () => {
- it('should render search input', () => {
- expect(vm.$el.querySelector('.search-input-container')).toBeDefined();
- });
-
- it('should render loading animation', done => {
- vm.$store.dispatch('fetchSearchedItems');
-
- Vue.nextTick(() => {
- const loadingEl = vm.$el.querySelector('.loading-animation');
-
- expect(loadingEl).toBeDefined();
- expect(loadingEl.classList.contains('prepend-top-20')).toBe(true);
- expect(loadingEl.querySelector('span').getAttribute('aria-label')).toBe('Loading projects');
- done();
- });
- });
-
- it('should render frequent projects list header', done => {
- Vue.nextTick(() => {
- const sectionHeaderEl = vm.$el.querySelector('.section-header');
-
- expect(sectionHeaderEl).toBeDefined();
- expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited');
- done();
- });
- });
-
- it('should render frequent projects list', done => {
- const expectedResult = getTopFrequentItems(mockFrequentProjects);
- spyOn(window.localStorage, 'getItem').and.callFake(() =>
- JSON.stringify(mockFrequentProjects),
- );
-
- expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1);
-
- vm.fetchFrequentItems();
- Vue.nextTick(() => {
- expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(
- expectedResult.length,
- );
- done();
- });
- });
-
- it('should render searched projects list', done => {
- mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects);
-
- expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1);
-
- vm.$store.dispatch('setSearchQuery', 'gitlab');
- vm.$nextTick()
- .then(() => {
- expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
- })
-
- // This test waits for multiple ticks in order to allow the responses to
- // propagate through each interceptor installed on the Axios instance.
- // This shouldn't be necessary; this test should be refactored to avoid this.
- // https://gitlab.com/gitlab-org/gitlab/issues/32479
- .then(vm.$nextTick)
- .then(vm.$nextTick)
- .then(vm.$nextTick)
-
- .then(() => {
- expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(
- mockSearchedProjects.data.length,
- );
- })
- .then(done)
- .catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/frequent_items/mock_data.js b/spec/javascripts/frequent_items/mock_data.js
deleted file mode 100644
index 419f70e41af..00000000000
--- a/spec/javascripts/frequent_items/mock_data.js
+++ /dev/null
@@ -1,168 +0,0 @@
-export const currentSession = {
- groups: {
- username: 'root',
- storageKey: 'root/frequent-groups',
- apiVersion: 'v4',
- group: {
- id: 1,
- name: 'dummy-group',
- full_name: 'dummy-parent-group',
- webUrl: `${gl.TEST_HOST}/dummy-group`,
- avatarUrl: null,
- lastAccessedOn: Date.now(),
- },
- },
- projects: {
- username: 'root',
- storageKey: 'root/frequent-projects',
- apiVersion: 'v4',
- project: {
- id: 1,
- name: 'dummy-project',
- namespace: 'SampleGroup / Dummy-Project',
- webUrl: `${gl.TEST_HOST}/samplegroup/dummy-project`,
- avatarUrl: null,
- lastAccessedOn: Date.now(),
- },
- },
-};
-
-export const mockNamespace = 'projects';
-
-export const mockStorageKey = 'test-user/frequent-projects';
-
-export const mockGroup = {
- id: 1,
- name: 'Sub451',
- namespace: 'Commit451 / Sub451',
- webUrl: `${gl.TEST_HOST}/Commit451/Sub451`,
- avatarUrl: null,
-};
-
-export const mockRawGroup = {
- id: 1,
- name: 'Sub451',
- full_name: 'Commit451 / Sub451',
- web_url: `${gl.TEST_HOST}/Commit451/Sub451`,
- avatar_url: null,
-};
-
-export const mockFrequentGroups = [
- {
- id: 3,
- name: 'Subgroup451',
- full_name: 'Commit451 / Subgroup451',
- webUrl: '/Commit451/Subgroup451',
- avatarUrl: null,
- frequency: 7,
- lastAccessedOn: 1497979281815,
- },
- {
- id: 1,
- name: 'Commit451',
- full_name: 'Commit451',
- webUrl: '/Commit451',
- avatarUrl: null,
- frequency: 3,
- lastAccessedOn: 1497979281815,
- },
-];
-
-export const mockSearchedGroups = [mockRawGroup];
-export const mockProcessedSearchedGroups = [mockGroup];
-
-export const mockProject = {
- id: 1,
- name: 'GitLab Community Edition',
- namespace: 'gitlab-org / gitlab-ce',
- webUrl: `${gl.TEST_HOST}/gitlab-org/gitlab-foss`,
- avatarUrl: null,
-};
-
-export const mockRawProject = {
- id: 1,
- name: 'GitLab Community Edition',
- name_with_namespace: 'gitlab-org / gitlab-ce',
- web_url: `${gl.TEST_HOST}/gitlab-org/gitlab-foss`,
- avatar_url: null,
-};
-
-export const mockFrequentProjects = [
- {
- id: 1,
- name: 'GitLab Community Edition',
- namespace: 'gitlab-org / gitlab-ce',
- webUrl: `${gl.TEST_HOST}/gitlab-org/gitlab-foss`,
- avatarUrl: null,
- frequency: 1,
- lastAccessedOn: Date.now(),
- },
- {
- id: 2,
- name: 'GitLab CI',
- namespace: 'gitlab-org / gitlab-ci',
- webUrl: `${gl.TEST_HOST}/gitlab-org/gitlab-ci`,
- avatarUrl: null,
- frequency: 9,
- lastAccessedOn: Date.now(),
- },
- {
- id: 3,
- name: 'Typeahead.Js',
- namespace: 'twitter / typeahead-js',
- webUrl: `${gl.TEST_HOST}/twitter/typeahead-js`,
- avatarUrl: '/uploads/-/system/project/avatar/7/TWBS.png',
- frequency: 2,
- lastAccessedOn: Date.now(),
- },
- {
- id: 4,
- name: 'Intel',
- namespace: 'platform / hardware / bsp / intel',
- webUrl: `${gl.TEST_HOST}/platform/hardware/bsp/intel`,
- avatarUrl: null,
- frequency: 3,
- lastAccessedOn: Date.now(),
- },
- {
- id: 5,
- name: 'v4.4',
- namespace: 'platform / hardware / bsp / kernel / common / v4.4',
- webUrl: `${gl.TEST_HOST}/platform/hardware/bsp/kernel/common/v4.4`,
- avatarUrl: null,
- frequency: 8,
- lastAccessedOn: Date.now(),
- },
-];
-
-export const mockSearchedProjects = { data: [mockRawProject] };
-export const mockProcessedSearchedProjects = [mockProject];
-
-export const unsortedFrequentItems = [
- { id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
- { id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
- { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
- { id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
- { id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
- { id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
- { id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
- { id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
- { id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
-];
-
-/**
- * This const has a specific order which tests authenticity
- * of `getTopFrequentItems` method so
- * DO NOT change order of items in this const.
- */
-export const sortedFrequentItems = [
- { id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
- { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
- { id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
- { id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
- { id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
- { id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
- { id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
- { id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
- { id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
-];
diff --git a/spec/javascripts/frequent_items/store/actions_spec.js b/spec/javascripts/frequent_items/store/actions_spec.js
deleted file mode 100644
index 7b065b69cce..00000000000
--- a/spec/javascripts/frequent_items/store/actions_spec.js
+++ /dev/null
@@ -1,228 +0,0 @@
-import testAction from 'spec/helpers/vuex_action_helper';
-import MockAdapter from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
-import AccessorUtilities from '~/lib/utils/accessor';
-import * as actions from '~/frequent_items/store/actions';
-import * as types from '~/frequent_items/store/mutation_types';
-import state from '~/frequent_items/store/state';
-import {
- mockNamespace,
- mockStorageKey,
- mockFrequentProjects,
- mockSearchedProjects,
-} from '../mock_data';
-
-describe('Frequent Items Dropdown Store Actions', () => {
- let mockedState;
- let mock;
-
- beforeEach(() => {
- mockedState = state();
- mock = new MockAdapter(axios);
-
- mockedState.namespace = mockNamespace;
- mockedState.storageKey = mockStorageKey;
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- describe('setNamespace', () => {
- it('should set namespace', done => {
- testAction(
- actions.setNamespace,
- mockNamespace,
- mockedState,
- [{ type: types.SET_NAMESPACE, payload: mockNamespace }],
- [],
- done,
- );
- });
- });
-
- describe('setStorageKey', () => {
- it('should set storage key', done => {
- testAction(
- actions.setStorageKey,
- mockStorageKey,
- mockedState,
- [{ type: types.SET_STORAGE_KEY, payload: mockStorageKey }],
- [],
- done,
- );
- });
- });
-
- describe('requestFrequentItems', () => {
- it('should request frequent items', done => {
- testAction(
- actions.requestFrequentItems,
- null,
- mockedState,
- [{ type: types.REQUEST_FREQUENT_ITEMS }],
- [],
- done,
- );
- });
- });
-
- describe('receiveFrequentItemsSuccess', () => {
- it('should set frequent items', done => {
- testAction(
- actions.receiveFrequentItemsSuccess,
- mockFrequentProjects,
- mockedState,
- [{ type: types.RECEIVE_FREQUENT_ITEMS_SUCCESS, payload: mockFrequentProjects }],
- [],
- done,
- );
- });
- });
-
- describe('receiveFrequentItemsError', () => {
- it('should set frequent items error state', done => {
- testAction(
- actions.receiveFrequentItemsError,
- null,
- mockedState,
- [{ type: types.RECEIVE_FREQUENT_ITEMS_ERROR }],
- [],
- done,
- );
- });
- });
-
- describe('fetchFrequentItems', () => {
- it('should dispatch `receiveFrequentItemsSuccess`', done => {
- mockedState.namespace = mockNamespace;
- mockedState.storageKey = mockStorageKey;
-
- testAction(
- actions.fetchFrequentItems,
- null,
- mockedState,
- [],
- [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsSuccess', payload: [] }],
- done,
- );
- });
-
- it('should dispatch `receiveFrequentItemsError`', done => {
- spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(false);
- mockedState.namespace = mockNamespace;
- mockedState.storageKey = mockStorageKey;
-
- testAction(
- actions.fetchFrequentItems,
- null,
- mockedState,
- [],
- [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsError' }],
- done,
- );
- });
- });
-
- describe('requestSearchedItems', () => {
- it('should request searched items', done => {
- testAction(
- actions.requestSearchedItems,
- null,
- mockedState,
- [{ type: types.REQUEST_SEARCHED_ITEMS }],
- [],
- done,
- );
- });
- });
-
- describe('receiveSearchedItemsSuccess', () => {
- it('should set searched items', done => {
- testAction(
- actions.receiveSearchedItemsSuccess,
- mockSearchedProjects,
- mockedState,
- [{ type: types.RECEIVE_SEARCHED_ITEMS_SUCCESS, payload: mockSearchedProjects }],
- [],
- done,
- );
- });
- });
-
- describe('receiveSearchedItemsError', () => {
- it('should set searched items error state', done => {
- testAction(
- actions.receiveSearchedItemsError,
- null,
- mockedState,
- [{ type: types.RECEIVE_SEARCHED_ITEMS_ERROR }],
- [],
- done,
- );
- });
- });
-
- describe('fetchSearchedItems', () => {
- beforeEach(() => {
- gon.api_version = 'v4';
- });
-
- it('should dispatch `receiveSearchedItemsSuccess`', done => {
- mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects, {});
-
- testAction(
- actions.fetchSearchedItems,
- null,
- mockedState,
- [],
- [
- { type: 'requestSearchedItems' },
- {
- type: 'receiveSearchedItemsSuccess',
- payload: { data: mockSearchedProjects, headers: {} },
- },
- ],
- done,
- );
- });
-
- it('should dispatch `receiveSearchedItemsError`', done => {
- gon.api_version = 'v4';
- mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(500);
-
- testAction(
- actions.fetchSearchedItems,
- null,
- mockedState,
- [],
- [{ type: 'requestSearchedItems' }, { type: 'receiveSearchedItemsError' }],
- done,
- );
- });
- });
-
- describe('setSearchQuery', () => {
- it('should commit query and dispatch `fetchSearchedItems` when query is present', done => {
- testAction(
- actions.setSearchQuery,
- { query: 'test' },
- mockedState,
- [{ type: types.SET_SEARCH_QUERY, payload: { query: 'test' } }],
- [{ type: 'fetchSearchedItems', payload: { query: 'test' } }],
- done,
- );
- });
-
- it('should commit query and dispatch `fetchFrequentItems` when query is empty', done => {
- testAction(
- actions.setSearchQuery,
- null,
- mockedState,
- [{ type: types.SET_SEARCH_QUERY, payload: null }],
- [{ type: 'fetchFrequentItems' }],
- done,
- );
- });
- });
-});
diff --git a/spec/javascripts/frequent_items/utils_spec.js b/spec/javascripts/frequent_items/utils_spec.js
deleted file mode 100644
index 2939b46bc31..00000000000
--- a/spec/javascripts/frequent_items/utils_spec.js
+++ /dev/null
@@ -1,130 +0,0 @@
-import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import {
- isMobile,
- getTopFrequentItems,
- updateExistingFrequentItem,
- sanitizeItem,
-} from '~/frequent_items/utils';
-import { HOUR_IN_MS, FREQUENT_ITEMS } from '~/frequent_items/constants';
-import { mockProject, unsortedFrequentItems, sortedFrequentItems } from './mock_data';
-
-describe('Frequent Items utils spec', () => {
- describe('isMobile', () => {
- it('returns true when the screen is medium ', () => {
- spyOn(bp, 'getBreakpointSize').and.returnValue('md');
-
- expect(isMobile()).toBe(true);
- });
-
- it('returns true when the screen is small ', () => {
- spyOn(bp, 'getBreakpointSize').and.returnValue('sm');
-
- expect(isMobile()).toBe(true);
- });
-
- it('returns true when the screen is extra-small ', () => {
- spyOn(bp, 'getBreakpointSize').and.returnValue('xs');
-
- expect(isMobile()).toBe(true);
- });
-
- it('returns false when the screen is larger than medium ', () => {
- spyOn(bp, 'getBreakpointSize').and.returnValue('lg');
-
- expect(isMobile()).toBe(false);
- });
- });
-
- describe('getTopFrequentItems', () => {
- it('returns empty array if no items provided', () => {
- const result = getTopFrequentItems();
-
- expect(result.length).toBe(0);
- });
-
- it('returns correct amount of items for mobile', () => {
- spyOn(bp, 'getBreakpointSize').and.returnValue('md');
- const result = getTopFrequentItems(unsortedFrequentItems);
-
- expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_MOBILE);
- });
-
- it('returns correct amount of items for desktop', () => {
- spyOn(bp, 'getBreakpointSize').and.returnValue('xl');
- const result = getTopFrequentItems(unsortedFrequentItems);
-
- expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_DESKTOP);
- });
-
- it('sorts frequent items in order of frequency and lastAccessedOn', () => {
- spyOn(bp, 'getBreakpointSize').and.returnValue('xl');
- const result = getTopFrequentItems(unsortedFrequentItems);
- const expectedResult = sortedFrequentItems.slice(0, FREQUENT_ITEMS.LIST_COUNT_DESKTOP);
-
- expect(result).toEqual(expectedResult);
- });
- });
-
- describe('updateExistingFrequentItem', () => {
- let mockedProject;
-
- beforeEach(() => {
- mockedProject = {
- ...mockProject,
- frequency: 1,
- lastAccessedOn: 1497979281815,
- };
- });
-
- it('updates item if accessed over an hour ago', () => {
- const newTimestamp = Date.now() + HOUR_IN_MS + 1;
- const newItem = {
- ...mockedProject,
- lastAccessedOn: newTimestamp,
- };
- const result = updateExistingFrequentItem(mockedProject, newItem);
-
- expect(result.frequency).toBe(mockedProject.frequency + 1);
- });
-
- it('does not update item if accessed within the hour', () => {
- const newItem = {
- ...mockedProject,
- lastAccessedOn: mockedProject.lastAccessedOn + HOUR_IN_MS,
- };
- const result = updateExistingFrequentItem(mockedProject, newItem);
-
- expect(result.frequency).toBe(mockedProject.frequency);
- });
- });
-
- describe('sanitizeItem', () => {
- it('strips HTML tags for name and namespace', () => {
- const input = {
- name: '<br><b>test</b>',
- namespace: '<br>test',
- id: 1,
- };
-
- expect(sanitizeItem(input)).toEqual({ name: 'test', namespace: 'test', id: 1 });
- });
-
- it("skips `name` key if it doesn't exist on the item", () => {
- const input = {
- namespace: '<br>test',
- id: 1,
- };
-
- expect(sanitizeItem(input)).toEqual({ namespace: 'test', id: 1 });
- });
-
- it("skips `namespace` key if it doesn't exist on the item", () => {
- const input = {
- name: '<br><b>test</b>',
- id: 1,
- };
-
- expect(sanitizeItem(input)).toEqual({ name: 'test', id: 1 });
- });
- });
-});
diff --git a/spec/javascripts/gl_dropdown_spec.js b/spec/javascripts/gl_dropdown_spec.js
index 00bc552bd7d..06f76c581f2 100644
--- a/spec/javascripts/gl_dropdown_spec.js
+++ b/spec/javascripts/gl_dropdown_spec.js
@@ -44,19 +44,17 @@ describe('glDropdown', function describeDropdown() {
};
function initDropDown(hasRemote, isFilterable, extraOpts = {}) {
- const options = Object.assign(
- {
- selectable: true,
- filterable: isFilterable,
- data: hasRemote ? remoteMock.bind({}, this.projectsData) : this.projectsData,
- search: {
- fields: ['name'],
- },
- text: project => project.name_with_namespace || project.name,
- id: project => project.id,
+ const options = {
+ selectable: true,
+ filterable: isFilterable,
+ data: hasRemote ? remoteMock.bind({}, this.projectsData) : this.projectsData,
+ search: {
+ fields: ['name'],
},
- extraOpts,
- );
+ text: project => project.name_with_namespace || project.name,
+ id: project => project.id,
+ ...extraOpts,
+ };
this.dropdownButtonElement = $(
'#js-project-dropdown',
this.dropdownContainerElement,
diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js
deleted file mode 100644
index 23b2564d3f9..00000000000
--- a/spec/javascripts/groups/components/app_spec.js
+++ /dev/null
@@ -1,533 +0,0 @@
-import '~/flash';
-import $ from 'jquery';
-import Vue from 'vue';
-
-import appComponent from '~/groups/components/app.vue';
-import groupFolderComponent from '~/groups/components/group_folder.vue';
-import groupItemComponent from '~/groups/components/group_item.vue';
-import eventHub from '~/groups/event_hub';
-import GroupsStore from '~/groups/store/groups_store';
-import GroupsService from '~/groups/service/groups_service';
-
-import {
- mockEndpoint,
- mockGroups,
- mockSearchedGroups,
- mockRawPageInfo,
- mockParentGroupItem,
- mockRawChildren,
- mockChildren,
- mockPageInfo,
-} from '../mock_data';
-
-const createComponent = (hideProjects = false) => {
- const Component = Vue.extend(appComponent);
- const store = new GroupsStore(false);
- const service = new GroupsService(mockEndpoint);
-
- store.state.pageInfo = mockPageInfo;
-
- return new Component({
- propsData: {
- store,
- service,
- hideProjects,
- },
- });
-};
-
-const returnServicePromise = (data, failed) =>
- new Promise((resolve, reject) => {
- if (failed) {
- reject(data);
- } else {
- resolve({
- json() {
- return data;
- },
- });
- }
- });
-
-describe('AppComponent', () => {
- let vm;
-
- beforeEach(done => {
- Vue.component('group-folder', groupFolderComponent);
- Vue.component('group-item', groupItemComponent);
-
- vm = createComponent();
-
- Vue.nextTick(() => {
- done();
- });
- });
-
- describe('computed', () => {
- beforeEach(() => {
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('groups', () => {
- it('should return list of groups from store', () => {
- spyOn(vm.store, 'getGroups');
-
- const { groups } = vm;
-
- expect(vm.store.getGroups).toHaveBeenCalled();
- expect(groups).not.toBeDefined();
- });
- });
-
- describe('pageInfo', () => {
- it('should return pagination info from store', () => {
- spyOn(vm.store, 'getPaginationInfo');
-
- const { pageInfo } = vm;
-
- expect(vm.store.getPaginationInfo).toHaveBeenCalled();
- expect(pageInfo).not.toBeDefined();
- });
- });
- });
-
- describe('methods', () => {
- beforeEach(() => {
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('fetchGroups', () => {
- it('should call `getGroups` with all the params provided', done => {
- spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups));
-
- vm.fetchGroups({
- parentId: 1,
- page: 2,
- filterGroupsBy: 'git',
- sortBy: 'created_desc',
- archived: true,
- });
- setTimeout(() => {
- expect(vm.service.getGroups).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true);
- done();
- }, 0);
- });
-
- it('should set headers to store for building pagination info when called with `updatePagination`', done => {
- spyOn(vm.service, 'getGroups').and.returnValue(
- returnServicePromise({ headers: mockRawPageInfo }),
- );
- spyOn(vm, 'updatePagination');
-
- vm.fetchGroups({ updatePagination: true });
- setTimeout(() => {
- expect(vm.service.getGroups).toHaveBeenCalled();
- expect(vm.updatePagination).toHaveBeenCalled();
- done();
- }, 0);
- });
-
- it('should show flash error when request fails', done => {
- spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true));
- spyOn($, 'scrollTo');
- spyOn(window, 'Flash');
-
- vm.fetchGroups({});
- setTimeout(() => {
- expect(vm.isLoading).toBe(false);
- expect($.scrollTo).toHaveBeenCalledWith(0);
- expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.');
- done();
- }, 0);
- });
- });
-
- describe('fetchAllGroups', () => {
- it('should fetch default set of groups', done => {
- spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
- spyOn(vm, 'updatePagination').and.callThrough();
- spyOn(vm, 'updateGroups').and.callThrough();
-
- vm.fetchAllGroups();
-
- expect(vm.isLoading).toBe(true);
- expect(vm.fetchGroups).toHaveBeenCalled();
- setTimeout(() => {
- expect(vm.isLoading).toBe(false);
- expect(vm.updateGroups).toHaveBeenCalled();
- done();
- }, 0);
- });
-
- it('should fetch matching set of groups when app is loaded with search query', done => {
- spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockSearchedGroups));
- spyOn(vm, 'updateGroups').and.callThrough();
-
- vm.fetchAllGroups();
-
- expect(vm.fetchGroups).toHaveBeenCalledWith({
- page: null,
- filterGroupsBy: null,
- sortBy: null,
- updatePagination: true,
- archived: null,
- });
- setTimeout(() => {
- expect(vm.updateGroups).toHaveBeenCalled();
- done();
- }, 0);
- });
- });
-
- describe('fetchPage', () => {
- it('should fetch groups for provided page details and update window state', done => {
- spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
- spyOn(vm, 'updateGroups').and.callThrough();
- const mergeUrlParams = spyOnDependency(appComponent, 'mergeUrlParams').and.callThrough();
- spyOn(window.history, 'replaceState');
- spyOn($, 'scrollTo');
-
- vm.fetchPage(2, null, null, true);
-
- expect(vm.isLoading).toBe(true);
- expect(vm.fetchGroups).toHaveBeenCalledWith({
- page: 2,
- filterGroupsBy: null,
- sortBy: null,
- updatePagination: true,
- archived: true,
- });
- setTimeout(() => {
- expect(vm.isLoading).toBe(false);
- expect($.scrollTo).toHaveBeenCalledWith(0);
- expect(mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String));
- expect(window.history.replaceState).toHaveBeenCalledWith(
- {
- page: jasmine.any(String),
- },
- jasmine.any(String),
- jasmine.any(String),
- );
-
- expect(vm.updateGroups).toHaveBeenCalled();
- done();
- }, 0);
- });
- });
-
- describe('toggleChildren', () => {
- let groupItem;
-
- beforeEach(() => {
- groupItem = Object.assign({}, mockParentGroupItem);
- groupItem.isOpen = false;
- groupItem.isChildrenLoading = false;
- });
-
- it('should fetch children of given group and expand it if group is collapsed and children are not loaded', done => {
- spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockRawChildren));
- spyOn(vm.store, 'setGroupChildren');
-
- vm.toggleChildren(groupItem);
-
- expect(groupItem.isChildrenLoading).toBe(true);
- expect(vm.fetchGroups).toHaveBeenCalledWith({
- parentId: groupItem.id,
- });
- setTimeout(() => {
- expect(vm.store.setGroupChildren).toHaveBeenCalled();
- done();
- }, 0);
- });
-
- it('should skip network request while expanding group if children are already loaded', () => {
- spyOn(vm, 'fetchGroups');
- groupItem.children = mockRawChildren;
-
- vm.toggleChildren(groupItem);
-
- expect(vm.fetchGroups).not.toHaveBeenCalled();
- expect(groupItem.isOpen).toBe(true);
- });
-
- it('should collapse group if it is already expanded', () => {
- spyOn(vm, 'fetchGroups');
- groupItem.isOpen = true;
-
- vm.toggleChildren(groupItem);
-
- expect(vm.fetchGroups).not.toHaveBeenCalled();
- expect(groupItem.isOpen).toBe(false);
- });
-
- it('should set `isChildrenLoading` back to `false` if load request fails', done => {
- spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true));
-
- vm.toggleChildren(groupItem);
-
- expect(groupItem.isChildrenLoading).toBe(true);
- setTimeout(() => {
- expect(groupItem.isChildrenLoading).toBe(false);
- done();
- }, 0);
- });
- });
-
- describe('showLeaveGroupModal', () => {
- it('caches candidate group (as props) which is to be left', () => {
- const group = Object.assign({}, mockParentGroupItem);
-
- expect(vm.targetGroup).toBe(null);
- expect(vm.targetParentGroup).toBe(null);
- vm.showLeaveGroupModal(group, mockParentGroupItem);
-
- expect(vm.targetGroup).not.toBe(null);
- expect(vm.targetParentGroup).not.toBe(null);
- });
-
- it('updates props which show modal confirmation dialog', () => {
- const group = Object.assign({}, mockParentGroupItem);
-
- expect(vm.showModal).toBe(false);
- expect(vm.groupLeaveConfirmationMessage).toBe('');
- vm.showLeaveGroupModal(group, mockParentGroupItem);
-
- expect(vm.showModal).toBe(true);
- expect(vm.groupLeaveConfirmationMessage).toBe(
- `Are you sure you want to leave the "${group.fullName}" group?`,
- );
- });
- });
-
- describe('hideLeaveGroupModal', () => {
- it('hides modal confirmation which is shown before leaving the group', () => {
- const group = Object.assign({}, mockParentGroupItem);
- vm.showLeaveGroupModal(group, mockParentGroupItem);
-
- expect(vm.showModal).toBe(true);
- vm.hideLeaveGroupModal();
-
- expect(vm.showModal).toBe(false);
- });
- });
-
- describe('leaveGroup', () => {
- let groupItem;
- let childGroupItem;
-
- beforeEach(() => {
- groupItem = Object.assign({}, mockParentGroupItem);
- groupItem.children = mockChildren;
- [childGroupItem] = groupItem.children;
- groupItem.isChildrenLoading = false;
- vm.targetGroup = childGroupItem;
- vm.targetParentGroup = groupItem;
- });
-
- it('hides modal confirmation leave group and remove group item from tree', done => {
- const notice = `You left the "${childGroupItem.fullName}" group.`;
- spyOn(vm.service, 'leaveGroup').and.returnValue(Promise.resolve({ data: { notice } }));
- spyOn(vm.store, 'removeGroup').and.callThrough();
- spyOn(window, 'Flash');
- spyOn($, 'scrollTo');
-
- vm.leaveGroup();
-
- expect(vm.showModal).toBe(false);
- expect(vm.targetGroup.isBeingRemoved).toBe(true);
- expect(vm.service.leaveGroup).toHaveBeenCalledWith(vm.targetGroup.leavePath);
- setTimeout(() => {
- expect($.scrollTo).toHaveBeenCalledWith(0);
- expect(vm.store.removeGroup).toHaveBeenCalledWith(vm.targetGroup, vm.targetParentGroup);
- expect(window.Flash).toHaveBeenCalledWith(notice, 'notice');
- done();
- }, 0);
- });
-
- it('should show error flash message if request failed to leave group', done => {
- const message = 'An error occurred. Please try again.';
- spyOn(vm.service, 'leaveGroup').and.returnValue(
- returnServicePromise({ status: 500 }, true),
- );
- spyOn(vm.store, 'removeGroup').and.callThrough();
- spyOn(window, 'Flash');
-
- vm.leaveGroup();
-
- expect(vm.targetGroup.isBeingRemoved).toBe(true);
- expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
- setTimeout(() => {
- expect(vm.store.removeGroup).not.toHaveBeenCalled();
- expect(window.Flash).toHaveBeenCalledWith(message);
- expect(vm.targetGroup.isBeingRemoved).toBe(false);
- done();
- }, 0);
- });
-
- it('should show appropriate error flash message if request forbids to leave group', done => {
- const message = 'Failed to leave the group. Please make sure you are not the only owner.';
- spyOn(vm.service, 'leaveGroup').and.returnValue(
- returnServicePromise({ status: 403 }, true),
- );
- spyOn(vm.store, 'removeGroup').and.callThrough();
- spyOn(window, 'Flash');
-
- vm.leaveGroup(childGroupItem, groupItem);
-
- expect(vm.targetGroup.isBeingRemoved).toBe(true);
- expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
- setTimeout(() => {
- expect(vm.store.removeGroup).not.toHaveBeenCalled();
- expect(window.Flash).toHaveBeenCalledWith(message);
- expect(vm.targetGroup.isBeingRemoved).toBe(false);
- done();
- }, 0);
- });
- });
-
- describe('updatePagination', () => {
- it('should set pagination info to store from provided headers', () => {
- spyOn(vm.store, 'setPaginationInfo');
-
- vm.updatePagination(mockRawPageInfo);
-
- expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo);
- });
- });
-
- describe('updateGroups', () => {
- it('should call setGroups on store if method was called directly', () => {
- spyOn(vm.store, 'setGroups');
-
- vm.updateGroups(mockGroups);
-
- expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups);
- });
-
- it('should call setSearchedGroups on store if method was called with fromSearch param', () => {
- spyOn(vm.store, 'setSearchedGroups');
-
- vm.updateGroups(mockGroups, true);
-
- expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups);
- });
-
- it('should set `isSearchEmpty` prop based on groups count', () => {
- vm.updateGroups(mockGroups);
-
- expect(vm.isSearchEmpty).toBe(false);
-
- vm.updateGroups([]);
-
- expect(vm.isSearchEmpty).toBe(true);
- });
- });
- });
-
- describe('created', () => {
- it('should bind event listeners on eventHub', done => {
- spyOn(eventHub, '$on');
-
- const newVm = createComponent();
- newVm.$mount();
-
- Vue.nextTick(() => {
- expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
- expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
- expect(eventHub.$on).toHaveBeenCalledWith('showLeaveGroupModal', jasmine.any(Function));
- expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
- expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
- newVm.$destroy();
- done();
- });
- });
-
- it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', done => {
- const newVm = createComponent();
- newVm.$mount();
- Vue.nextTick(() => {
- expect(newVm.searchEmptyMessage).toBe('No groups or projects matched your search');
- newVm.$destroy();
- done();
- });
- });
-
- it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', done => {
- const newVm = createComponent(true);
- newVm.$mount();
- Vue.nextTick(() => {
- expect(newVm.searchEmptyMessage).toBe('No groups matched your search');
- newVm.$destroy();
- done();
- });
- });
- });
-
- describe('beforeDestroy', () => {
- it('should unbind event listeners on eventHub', done => {
- spyOn(eventHub, '$off');
-
- const newVm = createComponent();
- newVm.$mount();
- newVm.$destroy();
-
- Vue.nextTick(() => {
- expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
- expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
- expect(eventHub.$off).toHaveBeenCalledWith('showLeaveGroupModal', jasmine.any(Function));
- expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
- expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
- done();
- });
- });
- });
-
- describe('template', () => {
- beforeEach(() => {
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('should render loading icon', done => {
- vm.isLoading = true;
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
- expect(vm.$el.querySelector('span').getAttribute('aria-label')).toBe('Loading groups');
- done();
- });
- });
-
- it('should render groups tree', done => {
- vm.store.state.groups = [mockParentGroupItem];
- vm.isLoading = false;
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
- done();
- });
- });
-
- it('renders modal confirmation dialog', done => {
- vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?';
- vm.showModal = true;
- Vue.nextTick(() => {
- const modalDialogEl = vm.$el.querySelector('.modal');
-
- expect(modalDialogEl).not.toBe(null);
- expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?');
- expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave');
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/groups/components/group_folder_spec.js b/spec/javascripts/groups/components/group_folder_spec.js
deleted file mode 100644
index fdfd1b82bd8..00000000000
--- a/spec/javascripts/groups/components/group_folder_spec.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import Vue from 'vue';
-
-import groupFolderComponent from '~/groups/components/group_folder.vue';
-import groupItemComponent from '~/groups/components/group_item.vue';
-import { mockGroups, mockParentGroupItem } from '../mock_data';
-
-const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => {
- const Component = Vue.extend(groupFolderComponent);
-
- return new Component({
- propsData: {
- groups,
- parentGroup,
- },
- });
-};
-
-describe('GroupFolderComponent', () => {
- let vm;
-
- beforeEach(done => {
- Vue.component('group-item', groupItemComponent);
-
- vm = createComponent();
- vm.$mount();
-
- Vue.nextTick(() => {
- done();
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('computed', () => {
- describe('hasMoreChildren', () => {
- it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => {
- expect(vm.hasMoreChildren).toBeFalsy();
- });
- });
-
- describe('moreChildrenStats', () => {
- it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => {
- expect(vm.moreChildrenStats).toBe('3 more items');
- });
- });
- });
-
- describe('template', () => {
- it('should render component template correctly', () => {
- expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy();
- expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7);
- });
-
- it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => {
- const parentGroup = Object.assign({}, mockParentGroupItem);
- parentGroup.childrenCount = 21;
-
- const newVm = createComponent(mockGroups, parentGroup);
- newVm.$mount();
-
- expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined();
- newVm.$destroy();
- });
- });
-});
diff --git a/spec/javascripts/groups/components/group_item_spec.js b/spec/javascripts/groups/components/group_item_spec.js
deleted file mode 100644
index 2889d7ae4ff..00000000000
--- a/spec/javascripts/groups/components/group_item_spec.js
+++ /dev/null
@@ -1,218 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import groupItemComponent from '~/groups/components/group_item.vue';
-import groupFolderComponent from '~/groups/components/group_folder.vue';
-import eventHub from '~/groups/event_hub';
-import { mockParentGroupItem, mockChildren } from '../mock_data';
-
-const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
- const Component = Vue.extend(groupItemComponent);
-
- return mountComponent(Component, {
- group,
- parentGroup,
- });
-};
-
-describe('GroupItemComponent', () => {
- let vm;
-
- beforeEach(done => {
- Vue.component('group-folder', groupFolderComponent);
-
- vm = createComponent();
-
- Vue.nextTick(() => {
- done();
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('computed', () => {
- describe('groupDomId', () => {
- it('should return ID string suffixed with group ID', () => {
- expect(vm.groupDomId).toBe('group-55');
- });
- });
-
- describe('rowClass', () => {
- it('should return map of classes based on group details', () => {
- const classes = ['is-open', 'has-children', 'has-description', 'being-removed'];
- const { rowClass } = vm;
-
- expect(Object.keys(rowClass).length).toBe(classes.length);
- Object.keys(rowClass).forEach(className => {
- expect(classes.indexOf(className)).toBeGreaterThan(-1);
- });
- });
- });
-
- describe('hasChildren', () => {
- it('should return boolean value representing if group has any children present', () => {
- let newVm;
- const group = Object.assign({}, mockParentGroupItem);
-
- group.childrenCount = 5;
- newVm = createComponent(group);
-
- expect(newVm.hasChildren).toBeTruthy();
- newVm.$destroy();
-
- group.childrenCount = 0;
- newVm = createComponent(group);
-
- expect(newVm.hasChildren).toBeFalsy();
- newVm.$destroy();
- });
- });
-
- describe('hasAvatar', () => {
- it('should return boolean value representing if group has any avatar present', () => {
- let newVm;
- const group = Object.assign({}, mockParentGroupItem);
-
- group.avatarUrl = null;
- newVm = createComponent(group);
-
- expect(newVm.hasAvatar).toBeFalsy();
- newVm.$destroy();
-
- group.avatarUrl = '/uploads/group_avatar.png';
- newVm = createComponent(group);
-
- expect(newVm.hasAvatar).toBeTruthy();
- newVm.$destroy();
- });
- });
-
- describe('isGroup', () => {
- it('should return boolean value representing if group item is of type `group` or not', () => {
- let newVm;
- const group = Object.assign({}, mockParentGroupItem);
-
- group.type = 'group';
- newVm = createComponent(group);
-
- expect(newVm.isGroup).toBeTruthy();
- newVm.$destroy();
-
- group.type = 'project';
- newVm = createComponent(group);
-
- expect(newVm.isGroup).toBeFalsy();
- newVm.$destroy();
- });
- });
- });
-
- describe('methods', () => {
- describe('onClickRowGroup', () => {
- let event;
-
- beforeEach(() => {
- const classList = {
- contains() {
- return false;
- },
- };
-
- event = {
- target: {
- classList,
- parentElement: {
- classList,
- },
- },
- };
- });
-
- it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => {
- spyOn(eventHub, '$emit');
-
- vm.onClickRowGroup(event);
-
- expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group);
- });
-
- it('should navigate page to group homepage if group does not have any children present', done => {
- const group = Object.assign({}, mockParentGroupItem);
- group.childrenCount = 0;
- const newVm = createComponent(group);
- const visitUrl = spyOnDependency(groupItemComponent, 'visitUrl').and.stub();
- spyOn(eventHub, '$emit');
-
- newVm.onClickRowGroup(event);
- setTimeout(() => {
- expect(eventHub.$emit).not.toHaveBeenCalled();
- expect(visitUrl).toHaveBeenCalledWith(newVm.group.relativePath);
- done();
- }, 0);
- });
- });
- });
-
- describe('template', () => {
- let group = null;
-
- describe('for a group pending deletion', () => {
- beforeEach(() => {
- group = { ...mockParentGroupItem, pendingRemoval: true };
- vm = createComponent(group);
- });
-
- it('renders the group pending removal badge', () => {
- const badgeEl = vm.$el.querySelector('.badge-warning');
-
- expect(badgeEl).toBeDefined();
- expect(badgeEl).toContainText('pending removal');
- });
- });
-
- describe('for a group not scheduled for deletion', () => {
- beforeEach(() => {
- group = { ...mockParentGroupItem, pendingRemoval: false };
- vm = createComponent(group);
- });
-
- it('does not render the group pending removal badge', () => {
- const groupTextContainer = vm.$el.querySelector('.group-text-container');
-
- expect(groupTextContainer).not.toContainText('pending removal');
- });
- });
-
- it('should render component template correctly', () => {
- const visibilityIconEl = vm.$el.querySelector('.item-visibility');
-
- expect(vm.$el.getAttribute('id')).toBe('group-55');
- expect(vm.$el.classList.contains('group-row')).toBeTruthy();
-
- expect(vm.$el.querySelector('.group-row-contents')).toBeDefined();
- expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined();
- expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined();
-
- expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined();
- expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined();
- expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined();
-
- expect(vm.$el.querySelector('.avatar-container')).toBeDefined();
- expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined();
- expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined();
-
- expect(vm.$el.querySelector('.title')).toBeDefined();
- expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined();
-
- expect(visibilityIconEl).not.toBe(null);
- expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip);
- expect(visibilityIconEl.querySelectorAll('svg').length).toBeGreaterThan(0);
-
- expect(vm.$el.querySelector('.access-type')).toBeDefined();
- expect(vm.$el.querySelector('.description')).toBeDefined();
-
- expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
- });
- });
-});
diff --git a/spec/javascripts/groups/components/groups_spec.js b/spec/javascripts/groups/components/groups_spec.js
deleted file mode 100644
index 8423467742e..00000000000
--- a/spec/javascripts/groups/components/groups_spec.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import Vue from 'vue';
-
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import groupsComponent from '~/groups/components/groups.vue';
-import groupFolderComponent from '~/groups/components/group_folder.vue';
-import groupItemComponent from '~/groups/components/group_item.vue';
-import eventHub from '~/groups/event_hub';
-import { mockGroups, mockPageInfo } from '../mock_data';
-
-const createComponent = (searchEmpty = false) => {
- const Component = Vue.extend(groupsComponent);
-
- return mountComponent(Component, {
- groups: mockGroups,
- pageInfo: mockPageInfo,
- searchEmptyMessage: 'No matching results',
- searchEmpty,
- });
-};
-
-describe('GroupsComponent', () => {
- let vm;
-
- beforeEach(done => {
- Vue.component('group-folder', groupFolderComponent);
- Vue.component('group-item', groupItemComponent);
-
- vm = createComponent();
-
- Vue.nextTick(() => {
- done();
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('methods', () => {
- describe('change', () => {
- it('should emit `fetchPage` event when page is changed via pagination', () => {
- spyOn(eventHub, '$emit').and.stub();
-
- vm.change(2);
-
- expect(eventHub.$emit).toHaveBeenCalledWith(
- 'fetchPage',
- 2,
- jasmine.any(Object),
- jasmine.any(Object),
- jasmine.any(Object),
- );
- });
- });
- });
-
- describe('template', () => {
- it('should render component template correctly', done => {
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
- expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
- expect(vm.$el.querySelector('.gl-pagination')).toBeDefined();
- expect(vm.$el.querySelectorAll('.has-no-search-results').length).toBe(0);
- done();
- });
- });
-
- it('should render empty search message when `searchEmpty` is `true`', done => {
- vm.searchEmpty = true;
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined();
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/groups/components/item_actions_spec.js b/spec/javascripts/groups/components/item_actions_spec.js
deleted file mode 100644
index 9a9d6208eac..00000000000
--- a/spec/javascripts/groups/components/item_actions_spec.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import Vue from 'vue';
-
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import itemActionsComponent from '~/groups/components/item_actions.vue';
-import eventHub from '~/groups/event_hub';
-import { mockParentGroupItem, mockChildren } from '../mock_data';
-
-const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
- const Component = Vue.extend(itemActionsComponent);
-
- return mountComponent(Component, {
- group,
- parentGroup,
- });
-};
-
-describe('ItemActionsComponent', () => {
- let vm;
-
- beforeEach(() => {
- vm = createComponent();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('methods', () => {
- describe('onLeaveGroup', () => {
- it('emits `showLeaveGroupModal` event with `group` and `parentGroup` props', () => {
- spyOn(eventHub, '$emit');
- vm.onLeaveGroup();
-
- expect(eventHub.$emit).toHaveBeenCalledWith(
- 'showLeaveGroupModal',
- vm.group,
- vm.parentGroup,
- );
- });
- });
- });
-
- describe('template', () => {
- it('should render component template correctly', () => {
- expect(vm.$el.classList.contains('controls')).toBeTruthy();
- });
-
- it('should render Edit Group button with correct attribute values', () => {
- const group = Object.assign({}, mockParentGroupItem);
- group.canEdit = true;
- const newVm = createComponent(group);
-
- const editBtn = newVm.$el.querySelector('a.edit-group');
-
- expect(editBtn).toBeDefined();
- expect(editBtn.classList.contains('no-expand')).toBeTruthy();
- 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');
-
- newVm.$destroy();
- });
-
- it('should render Leave Group button with correct attribute values', () => {
- const group = Object.assign({}, mockParentGroupItem);
- group.canLeave = true;
- const newVm = createComponent(group);
-
- const leaveBtn = newVm.$el.querySelector('a.leave-group');
-
- expect(leaveBtn).toBeDefined();
- expect(leaveBtn.classList.contains('no-expand')).toBeTruthy();
- 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');
-
- newVm.$destroy();
- });
- });
-});
diff --git a/spec/javascripts/groups/components/item_caret_spec.js b/spec/javascripts/groups/components/item_caret_spec.js
deleted file mode 100644
index 0eb56abbd61..00000000000
--- a/spec/javascripts/groups/components/item_caret_spec.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Vue from 'vue';
-
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import itemCaretComponent from '~/groups/components/item_caret.vue';
-
-const createComponent = (isGroupOpen = false) => {
- const Component = Vue.extend(itemCaretComponent);
-
- return mountComponent(Component, {
- isGroupOpen,
- });
-};
-
-describe('ItemCaretComponent', () => {
- describe('template', () => {
- it('should render component template correctly', () => {
- const vm = createComponent();
-
- expect(vm.$el.classList.contains('folder-caret')).toBeTruthy();
- expect(vm.$el.querySelectorAll('svg').length).toBe(1);
- vm.$destroy();
- });
-
- it('should render caret down icon if `isGroupOpen` prop is `true`', () => {
- const vm = createComponent(true);
-
- expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-down');
- vm.$destroy();
- });
-
- it('should render caret right icon if `isGroupOpen` prop is `false`', () => {
- const vm = createComponent();
-
- expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-right');
- vm.$destroy();
- });
- });
-});
diff --git a/spec/javascripts/groups/components/item_stats_spec.js b/spec/javascripts/groups/components/item_stats_spec.js
deleted file mode 100644
index 13d17b87d76..00000000000
--- a/spec/javascripts/groups/components/item_stats_spec.js
+++ /dev/null
@@ -1,128 +0,0 @@
-import Vue from 'vue';
-
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import itemStatsComponent from '~/groups/components/item_stats.vue';
-import {
- mockParentGroupItem,
- ITEM_TYPE,
- VISIBILITY_TYPE_ICON,
- GROUP_VISIBILITY_TYPE,
- PROJECT_VISIBILITY_TYPE,
-} from '../mock_data';
-
-const createComponent = (item = mockParentGroupItem) => {
- const Component = Vue.extend(itemStatsComponent);
-
- return mountComponent(Component, {
- item,
- });
-};
-
-describe('ItemStatsComponent', () => {
- describe('computed', () => {
- describe('visibilityIcon', () => {
- it('should return icon class based on `item.visibility` value', () => {
- Object.keys(VISIBILITY_TYPE_ICON).forEach(visibility => {
- const item = Object.assign({}, mockParentGroupItem, { visibility });
- const vm = createComponent(item);
-
- expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]);
- vm.$destroy();
- });
- });
- });
-
- describe('visibilityTooltip', () => {
- it('should return tooltip string for Group based on `item.visibility` value', () => {
- Object.keys(GROUP_VISIBILITY_TYPE).forEach(visibility => {
- const item = Object.assign({}, mockParentGroupItem, {
- visibility,
- type: ITEM_TYPE.GROUP,
- });
- const vm = createComponent(item);
-
- expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]);
- vm.$destroy();
- });
- });
-
- it('should return tooltip string for Project based on `item.visibility` value', () => {
- Object.keys(PROJECT_VISIBILITY_TYPE).forEach(visibility => {
- const item = Object.assign({}, mockParentGroupItem, {
- visibility,
- type: ITEM_TYPE.PROJECT,
- });
- const vm = createComponent(item);
-
- expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]);
- vm.$destroy();
- });
- });
- });
-
- describe('isProject', () => {
- it('should return boolean value representing whether `item.type` is Project or not', () => {
- let item;
- let vm;
-
- item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
- vm = createComponent(item);
-
- expect(vm.isProject).toBeTruthy();
- vm.$destroy();
-
- item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
- vm = createComponent(item);
-
- expect(vm.isProject).toBeFalsy();
- vm.$destroy();
- });
- });
-
- describe('isGroup', () => {
- it('should return boolean value representing whether `item.type` is Group or not', () => {
- let item;
- let vm;
-
- item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
- vm = createComponent(item);
-
- expect(vm.isGroup).toBeTruthy();
- vm.$destroy();
-
- item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
- vm = createComponent(item);
-
- expect(vm.isGroup).toBeFalsy();
- vm.$destroy();
- });
- });
- });
-
- describe('template', () => {
- it('renders component container element correctly', () => {
- const vm = createComponent();
-
- expect(vm.$el.classList.contains('stats')).toBeTruthy();
-
- vm.$destroy();
- });
-
- it('renders start count and last updated information for project item correctly', () => {
- const item = Object.assign({}, mockParentGroupItem, {
- type: ITEM_TYPE.PROJECT,
- starCount: 4,
- });
- const vm = createComponent(item);
-
- const projectStarIconEl = vm.$el.querySelector('.project-stars');
-
- expect(projectStarIconEl).not.toBeNull();
- expect(projectStarIconEl.querySelectorAll('svg').length).toBeGreaterThan(0);
- expect(projectStarIconEl.querySelectorAll('.stat-value').length).toBeGreaterThan(0);
- expect(vm.$el.querySelectorAll('.last-updated').length).toBeGreaterThan(0);
-
- vm.$destroy();
- });
- });
-});
diff --git a/spec/javascripts/groups/components/item_stats_value_spec.js b/spec/javascripts/groups/components/item_stats_value_spec.js
deleted file mode 100644
index ff4e781ce1a..00000000000
--- a/spec/javascripts/groups/components/item_stats_value_spec.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import Vue from 'vue';
-
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import itemStatsValueComponent from '~/groups/components/item_stats_value.vue';
-
-const createComponent = ({ title, cssClass, iconName, tooltipPlacement, value }) => {
- const Component = Vue.extend(itemStatsValueComponent);
-
- return mountComponent(Component, {
- title,
- cssClass,
- iconName,
- tooltipPlacement,
- value,
- });
-};
-
-describe('ItemStatsValueComponent', () => {
- describe('computed', () => {
- let vm;
- const itemConfig = {
- title: 'Subgroups',
- cssClass: 'number-subgroups',
- iconName: 'folder',
- tooltipPlacement: 'left',
- };
-
- describe('isValuePresent', () => {
- it('returns true if non-empty `value` is present', () => {
- vm = createComponent(Object.assign({}, itemConfig, { value: 10 }));
-
- expect(vm.isValuePresent).toBeTruthy();
- });
-
- it('returns false if empty `value` is present', () => {
- vm = createComponent(itemConfig);
-
- expect(vm.isValuePresent).toBeFalsy();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
- });
- });
-
- describe('template', () => {
- let vm;
- beforeEach(() => {
- vm = createComponent({
- title: 'Subgroups',
- cssClass: 'number-subgroups',
- iconName: 'folder',
- tooltipPlacement: 'left',
- value: 10,
- });
- });
-
- it('renders component element correctly', () => {
- expect(vm.$el.classList.contains('number-subgroups')).toBeTruthy();
- expect(vm.$el.querySelectorAll('svg').length).toBeGreaterThan(0);
- expect(vm.$el.querySelectorAll('.stat-value').length).toBeGreaterThan(0);
- });
-
- it('renders element tooltip correctly', () => {
- expect(vm.$el.dataset.originalTitle).toBe('Subgroups');
- expect(vm.$el.dataset.placement).toBe('left');
- });
-
- it('renders element icon correctly', () => {
- expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('folder');
- });
-
- it('renders value count correctly', () => {
- expect(vm.$el.querySelector('.stat-value').innerText.trim()).toContain('10');
- });
-
- afterEach(() => {
- vm.$destroy();
- });
- });
-});
diff --git a/spec/javascripts/groups/components/item_type_icon_spec.js b/spec/javascripts/groups/components/item_type_icon_spec.js
deleted file mode 100644
index 321712e54a6..00000000000
--- a/spec/javascripts/groups/components/item_type_icon_spec.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import Vue from 'vue';
-
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import itemTypeIconComponent from '~/groups/components/item_type_icon.vue';
-import { ITEM_TYPE } from '../mock_data';
-
-const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => {
- const Component = Vue.extend(itemTypeIconComponent);
-
- return mountComponent(Component, {
- itemType,
- isGroupOpen,
- });
-};
-
-describe('ItemTypeIconComponent', () => {
- describe('template', () => {
- it('should render component template correctly', () => {
- const vm = createComponent();
- vm.$mount();
-
- expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy();
- vm.$destroy();
- });
-
- it('should render folder open or close icon based `isGroupOpen` prop value', () => {
- let vm;
-
- vm = createComponent(ITEM_TYPE.GROUP, true);
- vm.$mount();
-
- expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder-open');
- vm.$destroy();
-
- vm = createComponent(ITEM_TYPE.GROUP);
- vm.$mount();
-
- expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder');
- vm.$destroy();
- });
-
- it('should render bookmark icon based on `isProject` prop value', () => {
- let vm;
-
- vm = createComponent(ITEM_TYPE.PROJECT);
- vm.$mount();
-
- expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('bookmark');
- vm.$destroy();
-
- vm = createComponent(ITEM_TYPE.GROUP);
- vm.$mount();
-
- expect(vm.$el.querySelector('use').getAttribute('xlink:href')).not.toContain('bookmark');
- vm.$destroy();
- });
- });
-});
diff --git a/spec/javascripts/groups/service/groups_service_spec.js b/spec/javascripts/groups/service/groups_service_spec.js
deleted file mode 100644
index 45db962a1ef..00000000000
--- a/spec/javascripts/groups/service/groups_service_spec.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-
-import GroupsService from '~/groups/service/groups_service';
-import { mockEndpoint, mockParentGroupItem } from '../mock_data';
-
-describe('GroupsService', () => {
- let service;
-
- beforeEach(() => {
- service = new GroupsService(mockEndpoint);
- });
-
- describe('getGroups', () => {
- it('should return promise for `GET` request on provided endpoint', () => {
- spyOn(axios, 'get').and.stub();
- const params = {
- page: 2,
- filter: 'git',
- sort: 'created_asc',
- archived: true,
- };
-
- service.getGroups(55, 2, 'git', 'created_asc', true);
-
- expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params: { parent_id: 55 } });
-
- service.getGroups(null, 2, 'git', 'created_asc', true);
-
- expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params });
- });
- });
-
- describe('leaveGroup', () => {
- it('should return promise for `DELETE` request on provided endpoint', () => {
- spyOn(axios, 'delete').and.stub();
-
- service.leaveGroup(mockParentGroupItem.leavePath);
-
- expect(axios.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath);
- });
- });
-});
diff --git a/spec/javascripts/groups/store/groups_store_spec.js b/spec/javascripts/groups/store/groups_store_spec.js
deleted file mode 100644
index 38de4b89f31..00000000000
--- a/spec/javascripts/groups/store/groups_store_spec.js
+++ /dev/null
@@ -1,123 +0,0 @@
-import GroupsStore from '~/groups/store/groups_store';
-import {
- mockGroups,
- mockSearchedGroups,
- mockParentGroupItem,
- mockRawChildren,
- mockRawPageInfo,
-} from '../mock_data';
-
-describe('ProjectsStore', () => {
- describe('constructor', () => {
- it('should initialize default state', () => {
- let store;
-
- store = new GroupsStore();
-
- expect(Object.keys(store.state).length).toBe(2);
- expect(Array.isArray(store.state.groups)).toBeTruthy();
- expect(Object.keys(store.state.pageInfo).length).toBe(0);
- expect(store.hideProjects).not.toBeDefined();
-
- store = new GroupsStore(true);
-
- expect(store.hideProjects).toBeTruthy();
- });
- });
-
- describe('setGroups', () => {
- it('should set groups to state', () => {
- const store = new GroupsStore();
- spyOn(store, 'formatGroupItem').and.callThrough();
-
- store.setGroups(mockGroups);
-
- expect(store.state.groups.length).toBe(mockGroups.length);
- expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
- expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1);
- });
- });
-
- describe('setSearchedGroups', () => {
- it('should set searched groups to state', () => {
- const store = new GroupsStore();
- spyOn(store, 'formatGroupItem').and.callThrough();
-
- store.setSearchedGroups(mockSearchedGroups);
-
- expect(store.state.groups.length).toBe(mockSearchedGroups.length);
- expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
- expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1);
- expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName')).toBeGreaterThan(
- -1,
- );
- });
- });
-
- describe('setGroupChildren', () => {
- it('should set children to group item in state', () => {
- const store = new GroupsStore();
- spyOn(store, 'formatGroupItem').and.callThrough();
-
- store.setGroupChildren(mockParentGroupItem, mockRawChildren);
-
- expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
- expect(mockParentGroupItem.children.length).toBe(1);
- expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName')).toBeGreaterThan(-1);
- expect(mockParentGroupItem.isOpen).toBeTruthy();
- expect(mockParentGroupItem.isChildrenLoading).toBeFalsy();
- });
- });
-
- describe('setPaginationInfo', () => {
- it('should parse and set pagination info in state', () => {
- const store = new GroupsStore();
-
- store.setPaginationInfo(mockRawPageInfo);
-
- expect(store.state.pageInfo.perPage).toBe(10);
- expect(store.state.pageInfo.page).toBe(10);
- expect(store.state.pageInfo.total).toBe(10);
- expect(store.state.pageInfo.totalPages).toBe(10);
- expect(store.state.pageInfo.nextPage).toBe(10);
- expect(store.state.pageInfo.previousPage).toBe(10);
- });
- });
-
- describe('formatGroupItem', () => {
- it('should parse group item object and return updated object', () => {
- let store;
- let updatedGroupItem;
-
- store = new GroupsStore();
- updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
-
- expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1);
- expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count);
- expect(updatedGroupItem.isChildrenLoading).toBe(false);
- expect(updatedGroupItem.isBeingRemoved).toBe(false);
-
- store = new GroupsStore(true);
- updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
-
- expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1);
- expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count);
- });
- });
-
- describe('removeGroup', () => {
- it('should remove children from group item in state', () => {
- const store = new GroupsStore();
- const rawParentGroup = Object.assign({}, mockGroups[0]);
- const rawChildGroup = Object.assign({}, mockGroups[1]);
-
- store.setGroups([rawParentGroup]);
- store.setGroupChildren(store.state.groups[0], [rawChildGroup]);
- const childItem = store.state.groups[0].children[0];
-
- store.removeGroup(childItem, store.state.groups[0]);
-
- expect(store.state.groups[0].children.length).toBe(0);
- });
- });
-});
diff --git a/spec/javascripts/helpers/filtered_search_spec_helper.js b/spec/javascripts/helpers/filtered_search_spec_helper.js
index ceb7982bbc3..de17518ea51 100644
--- a/spec/javascripts/helpers/filtered_search_spec_helper.js
+++ b/spec/javascripts/helpers/filtered_search_spec_helper.js
@@ -1,69 +1 @@
-export default class FilteredSearchSpecHelper {
- static createFilterVisualTokenHTML(name, operator, value, isSelected) {
- return FilteredSearchSpecHelper.createFilterVisualToken(name, operator, value, isSelected)
- .outerHTML;
- }
-
- static createFilterVisualToken(name, operator, value, isSelected = false) {
- const li = document.createElement('li');
- li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`);
-
- li.innerHTML = `
- <div class="selectable ${isSelected ? 'selected' : ''}" role="button">
- <div class="name">${name}</div>
- <div class="operator">${operator}</div>
- <div class="value-container">
- <div class="value">${value}</div>
- <div class="remove-token" role="button">
- <i class="fa fa-close"></i>
- </div>
- </div>
- </div>
- `;
-
- return li;
- }
-
- static createNameFilterVisualTokenHTML(name) {
- return `
- <li class="js-visual-token filtered-search-token">
- <div class="name">${name}</div>
- </li>
- `;
- }
-
- static createNameOperatorFilterVisualTokenHTML(name, operator) {
- return `
- <li class="js-visual-token filtered-search-token">
- <div class="name">${name}</div>
- <div class="operator">${operator}</div>
- </li>
- `;
- }
-
- static createSearchVisualToken(name) {
- const li = document.createElement('li');
- li.classList.add('js-visual-token', 'filtered-search-term');
- li.innerHTML = `<div class="name">${name}</div>`;
- return li;
- }
-
- static createSearchVisualTokenHTML(name) {
- return FilteredSearchSpecHelper.createSearchVisualToken(name).outerHTML;
- }
-
- static createInputHTML(placeholder = '', value = '') {
- return `
- <li class="input-token">
- <input type='text' class='filtered-search' placeholder='${placeholder}' value='${value}'/>
- </li>
- `;
- }
-
- static createTokensContainerHTML(html, inputPlaceholder) {
- return `
- ${html}
- ${FilteredSearchSpecHelper.createInputHTML(inputPlaceholder)}
- `;
- }
-}
+export { default } from '../../frontend/helpers/filtered_search_spec_helper';
diff --git a/spec/javascripts/helpers/init_vue_mr_page_helper.js b/spec/javascripts/helpers/init_vue_mr_page_helper.js
index 04f969fcd2d..1ba08199764 100644
--- a/spec/javascripts/helpers/init_vue_mr_page_helper.js
+++ b/spec/javascripts/helpers/init_vue_mr_page_helper.js
@@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import initMRPage from '~/mr_notes/index';
import axios from '~/lib/utils/axios_utils';
import { userDataMock, notesDataMock, noteableDataMock } from '../../frontend/notes/mock_data';
-import diffFileMockData from '../diffs/mock_data/diff_file';
+import diffFileMockData from '../../frontend/diffs/mock_data/diff_file';
export default function initVueMRPage() {
const mrTestEl = document.createElement('div');
diff --git a/spec/javascripts/helpers/vue_mount_component_helper.js b/spec/javascripts/helpers/vue_mount_component_helper.js
index 6848c95d95d..c1857115b61 100644
--- a/spec/javascripts/helpers/vue_mount_component_helper.js
+++ b/spec/javascripts/helpers/vue_mount_component_helper.js
@@ -1,38 +1,2 @@
-import Vue from 'vue';
-
-const mountComponent = (Component, props = {}, el = null) =>
- new Component({
- propsData: props,
- }).$mount(el);
-
-export const createComponentWithStore = (Component, store, propsData = {}) =>
- new Component({
- store,
- propsData,
- });
-
-export const mountComponentWithStore = (Component, { el, props, store }) =>
- new Component({
- store,
- propsData: props || {},
- }).$mount(el);
-
-export const mountComponentWithSlots = (Component, { props, slots }) => {
- const component = new Component({
- propsData: props || {},
- });
-
- component.$slots = slots;
-
- return component.$mount();
-};
-
-/**
- * Mount a component with the given render method.
- *
- * This helps with inserting slots that need to be compiled.
- */
-export const mountComponentWithRender = (render, el = null) =>
- mountComponent(Vue.extend({ render }), {}, el);
-
-export default mountComponent;
+export { default } from '../../frontend/helpers/vue_mount_component_helper';
+export * from '../../frontend/helpers/vue_mount_component_helper';
diff --git a/spec/javascripts/ide/components/activity_bar_spec.js b/spec/javascripts/ide/components/activity_bar_spec.js
deleted file mode 100644
index 823ca29dab9..00000000000
--- a/spec/javascripts/ide/components/activity_bar_spec.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import { leftSidebarViews } from '~/ide/constants';
-import ActivityBar from '~/ide/components/activity_bar.vue';
-import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
-import { resetStore } from '../helpers';
-
-describe('IDE activity bar', () => {
- const Component = Vue.extend(ActivityBar);
- let vm;
-
- beforeEach(() => {
- Vue.set(store.state.projects, 'abcproject', {
- web_url: 'testing',
- });
- Vue.set(store.state, 'currentProjectId', 'abcproject');
-
- vm = createComponentWithStore(Component, store);
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- describe('updateActivityBarView', () => {
- beforeEach(() => {
- spyOn(vm, 'updateActivityBarView');
-
- vm.$mount();
- });
-
- it('calls updateActivityBarView with edit value on click', () => {
- vm.$el.querySelector('.js-ide-edit-mode').click();
-
- expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.edit.name);
- });
-
- it('calls updateActivityBarView with commit value on click', () => {
- vm.$el.querySelector('.js-ide-commit-mode').click();
-
- expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.commit.name);
- });
-
- it('calls updateActivityBarView with review value on click', () => {
- vm.$el.querySelector('.js-ide-review-mode').click();
-
- expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.review.name);
- });
- });
-
- describe('active item', () => {
- beforeEach(() => {
- vm.$mount();
- });
-
- it('sets edit item active', () => {
- expect(vm.$el.querySelector('.js-ide-edit-mode').classList).toContain('active');
- });
-
- it('sets commit item active', done => {
- vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.js-ide-commit-mode').classList).toContain('active');
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js
deleted file mode 100644
index b30f0e6822b..00000000000
--- a/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js
+++ /dev/null
@@ -1,139 +0,0 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import { resetStore } from 'spec/ide/helpers';
-import store from '~/ide/stores';
-import radioGroup from '~/ide/components/commit_sidebar/radio_group.vue';
-
-describe('IDE commit sidebar radio group', () => {
- let vm;
-
- beforeEach(done => {
- const Component = Vue.extend(radioGroup);
-
- store.state.commit.commitAction = '2';
-
- vm = createComponentWithStore(Component, store, {
- value: '1',
- label: 'test',
- checked: true,
- });
-
- vm.$mount();
-
- Vue.nextTick(done);
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('uses label if present', () => {
- expect(vm.$el.textContent).toContain('test');
- });
-
- it('uses slot if label is not present', done => {
- vm.$destroy();
-
- vm = new Vue({
- components: {
- radioGroup,
- },
- store,
- template: `
- <radio-group
- value="1"
- >
- Testing slot
- </radio-group>
- `,
- });
-
- vm.$mount();
-
- Vue.nextTick(() => {
- expect(vm.$el.textContent).toContain('Testing slot');
-
- done();
- });
- });
-
- it('updates store when changing radio button', done => {
- vm.$el.querySelector('input').dispatchEvent(new Event('change'));
-
- Vue.nextTick(() => {
- expect(store.state.commit.commitAction).toBe('1');
-
- done();
- });
- });
-
- describe('with input', () => {
- beforeEach(done => {
- vm.$destroy();
-
- const Component = Vue.extend(radioGroup);
-
- store.state.commit.commitAction = '1';
- store.state.commit.newBranchName = 'test-123';
-
- vm = createComponentWithStore(Component, store, {
- value: '1',
- label: 'test',
- checked: true,
- showInput: true,
- });
-
- vm.$mount();
-
- Vue.nextTick(done);
- });
-
- it('renders input box when commitAction matches value', () => {
- expect(vm.$el.querySelector('.form-control')).not.toBeNull();
- });
-
- it('hides input when commitAction doesnt match value', done => {
- store.state.commit.commitAction = '2';
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.form-control')).toBeNull();
- done();
- });
- });
-
- it('updates branch name in store on input', done => {
- const input = vm.$el.querySelector('.form-control');
- input.value = 'testing-123';
- input.dispatchEvent(new Event('input'));
-
- Vue.nextTick(() => {
- expect(store.state.commit.newBranchName).toBe('testing-123');
-
- done();
- });
- });
-
- it('renders newBranchName if present', () => {
- const input = vm.$el.querySelector('.form-control');
-
- expect(input.value).toBe('test-123');
- });
- });
-
- describe('tooltipTitle', () => {
- it('returns title when disabled', () => {
- vm.title = 'test title';
- vm.disabled = true;
-
- expect(vm.tooltipTitle).toBe('test title');
- });
-
- it('returns blank when not disabled', () => {
- vm.title = 'test title';
-
- expect(vm.tooltipTitle).not.toBe('test title');
- });
- });
-});
diff --git a/spec/javascripts/ide/components/file_row_extra_spec.js b/spec/javascripts/ide/components/file_row_extra_spec.js
deleted file mode 100644
index 9fd014b50ef..00000000000
--- a/spec/javascripts/ide/components/file_row_extra_spec.js
+++ /dev/null
@@ -1,170 +0,0 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import { createStore } from '~/ide/stores';
-import FileRowExtra from '~/ide/components/file_row_extra.vue';
-import { file, resetStore } from '../helpers';
-
-describe('IDE extra file row component', () => {
- let Component;
- let vm;
- let unstagedFilesCount = 0;
- let stagedFilesCount = 0;
- let changesCount = 0;
-
- beforeAll(() => {
- Component = Vue.extend(FileRowExtra);
- });
-
- beforeEach(() => {
- vm = createComponentWithStore(Component, createStore(), {
- file: {
- ...file('test'),
- },
- dropdownOpen: false,
- });
-
- spyOnProperty(vm, 'getUnstagedFilesCountForPath').and.returnValue(() => unstagedFilesCount);
- spyOnProperty(vm, 'getStagedFilesCountForPath').and.returnValue(() => stagedFilesCount);
- spyOnProperty(vm, 'getChangesInFolder').and.returnValue(() => changesCount);
-
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
- resetStore(vm.$store);
-
- stagedFilesCount = 0;
- unstagedFilesCount = 0;
- changesCount = 0;
- });
-
- describe('folderChangesTooltip', () => {
- it('returns undefined when changes count is 0', () => {
- changesCount = 0;
-
- expect(vm.folderChangesTooltip).toBe(undefined);
- });
-
- [{ input: 1, output: '1 changed file' }, { input: 2, output: '2 changed files' }].forEach(
- ({ input, output }) => {
- it('returns changed files count if changes count is not 0', () => {
- changesCount = input;
-
- expect(vm.folderChangesTooltip).toBe(output);
- });
- },
- );
- });
-
- describe('show tree changes count', () => {
- it('does not show for blobs', () => {
- vm.file.type = 'blob';
-
- expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
- });
-
- it('does not show when changes count is 0', () => {
- vm.file.type = 'tree';
-
- expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
- });
-
- it('does not show when tree is open', done => {
- vm.file.type = 'tree';
- vm.file.opened = true;
- changesCount = 1;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
-
- done();
- });
- });
-
- it('shows for trees with changes', done => {
- vm.file.type = 'tree';
- vm.file.opened = false;
- changesCount = 1;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.ide-tree-changes')).not.toBe(null);
-
- done();
- });
- });
- });
-
- describe('changes file icon', () => {
- it('hides when file is not changed', () => {
- expect(vm.$el.querySelector('.file-changed-icon')).toBe(null);
- });
-
- it('shows when file is changed', done => {
- vm.file.changed = true;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
-
- done();
- });
- });
-
- it('shows when file is staged', done => {
- vm.file.staged = true;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
-
- done();
- });
- });
-
- it('shows when file is a tempFile', done => {
- vm.file.tempFile = true;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
-
- done();
- });
- });
-
- it('shows when file is renamed', done => {
- vm.file.prevPath = 'original-file';
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
-
- done();
- });
- });
-
- it('hides when file is renamed', done => {
- vm.file.prevPath = 'original-file';
- vm.file.type = 'tree';
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.file-changed-icon')).toBe(null);
-
- done();
- });
- });
- });
-
- describe('merge request icon', () => {
- it('hides when not a merge request change', () => {
- expect(vm.$el.querySelector('.ic-git-merge')).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);
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/ide/components/file_templates/bar_spec.js b/spec/javascripts/ide/components/file_templates/bar_spec.js
deleted file mode 100644
index 5399ada94ae..00000000000
--- a/spec/javascripts/ide/components/file_templates/bar_spec.js
+++ /dev/null
@@ -1,117 +0,0 @@
-import Vue from 'vue';
-import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import { createStore } from '~/ide/stores';
-import Bar from '~/ide/components/file_templates/bar.vue';
-import { resetStore, file } from '../../helpers';
-
-describe('IDE file templates bar component', () => {
- let Component;
- let vm;
-
- beforeAll(() => {
- Component = Vue.extend(Bar);
- });
-
- beforeEach(() => {
- const store = createStore();
-
- store.state.openFiles.push({
- ...file('file'),
- opened: true,
- active: true,
- });
-
- vm = mountComponentWithStore(Component, { store });
- });
-
- afterEach(() => {
- vm.$destroy();
- resetStore(vm.$store);
- });
-
- describe('template type dropdown', () => {
- it('renders dropdown component', () => {
- expect(vm.$el.querySelector('.dropdown').textContent).toContain('Choose a type');
- });
-
- it('calls setSelectedTemplateType when clicking item', () => {
- spyOn(vm, 'setSelectedTemplateType').and.stub();
-
- vm.$el.querySelector('.dropdown-content button').click();
-
- expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({
- name: '.gitlab-ci.yml',
- key: 'gitlab_ci_ymls',
- });
- });
- });
-
- describe('template dropdown', () => {
- beforeEach(done => {
- vm.$store.state.fileTemplates.templates = [
- {
- name: 'test',
- },
- ];
- vm.$store.state.fileTemplates.selectedTemplateType = {
- name: '.gitlab-ci.yml',
- key: 'gitlab_ci_ymls',
- };
-
- vm.$nextTick(done);
- });
-
- it('renders dropdown component', () => {
- expect(vm.$el.querySelectorAll('.dropdown')[1].textContent).toContain('Choose a template');
- });
-
- it('calls fetchTemplate on click', () => {
- spyOn(vm, 'fetchTemplate').and.stub();
-
- vm.$el
- .querySelectorAll('.dropdown-content')[1]
- .querySelector('button')
- .click();
-
- expect(vm.fetchTemplate).toHaveBeenCalledWith({
- name: 'test',
- });
- });
- });
-
- it('shows undo button if updateSuccess is true', done => {
- vm.$store.state.fileTemplates.updateSuccess = true;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.btn-default').style.display).not.toBe('none');
-
- done();
- });
- });
-
- it('calls undoFileTemplate when clicking undo button', () => {
- spyOn(vm, 'undoFileTemplate').and.stub();
-
- vm.$el.querySelector('.btn-default').click();
-
- expect(vm.undoFileTemplate).toHaveBeenCalled();
- });
-
- it('calls setSelectedTemplateType if activeFile name matches a template', done => {
- const fileName = '.gitlab-ci.yml';
-
- spyOn(vm, 'setSelectedTemplateType');
- vm.$store.state.openFiles[0].name = fileName;
-
- vm.setInitialType();
-
- vm.$nextTick(() => {
- expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({
- name: fileName,
- key: 'gitlab_ci_ymls',
- });
-
- done();
- });
- });
-});
diff --git a/spec/javascripts/ide/components/ide_review_spec.js b/spec/javascripts/ide/components/ide_review_spec.js
deleted file mode 100644
index 396c5d282d4..00000000000
--- a/spec/javascripts/ide/components/ide_review_spec.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import Vue from 'vue';
-import IdeReview from '~/ide/components/ide_review.vue';
-import store from '~/ide/stores';
-import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
-import { trimText } from '../../helpers/text_helper';
-import { resetStore, file } from '../helpers';
-import { projectData } from '../mock_data';
-
-describe('IDE review mode', () => {
- const Component = Vue.extend(IdeReview);
- let vm;
-
- beforeEach(() => {
- store.state.currentProjectId = 'abcproject';
- store.state.currentBranchId = 'master';
- store.state.projects.abcproject = Object.assign({}, projectData);
- Vue.set(store.state.trees, 'abcproject/master', {
- tree: [file('fileName')],
- loading: false,
- });
-
- vm = createComponentWithStore(Component, store).$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders list of files', () => {
- expect(vm.$el.textContent).toContain('fileName');
- });
-
- describe('merge request', () => {
- beforeEach(done => {
- store.state.currentMergeRequestId = '1';
- store.state.projects.abcproject.mergeRequests['1'] = {
- iid: 123,
- web_url: 'testing123',
- };
-
- vm.$nextTick(done);
- });
-
- it('renders edit dropdown', () => {
- expect(vm.$el.querySelector('.btn')).not.toBe(null);
- });
-
- it('renders merge request link & IID', () => {
- const link = vm.$el.querySelector('.ide-review-sub-header');
-
- expect(link.querySelector('a').getAttribute('href')).toBe('testing123');
- expect(trimText(link.textContent)).toBe('Merge request (!123)');
- });
-
- it('changes text to latest changes when viewer is not mrdiff', done => {
- store.state.viewer = 'diff';
-
- vm.$nextTick(() => {
- expect(trimText(vm.$el.querySelector('.ide-review-sub-header').textContent)).toBe(
- 'Latest changes',
- );
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/ide/components/ide_side_bar_spec.js b/spec/javascripts/ide/components/ide_side_bar_spec.js
deleted file mode 100644
index 28f127a61c0..00000000000
--- a/spec/javascripts/ide/components/ide_side_bar_spec.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import store from '~/ide/stores';
-import ideSidebar from '~/ide/components/ide_side_bar.vue';
-import { leftSidebarViews } from '~/ide/constants';
-import { resetStore } from '../helpers';
-import { projectData } from '../mock_data';
-
-describe('IdeSidebar', () => {
- let vm;
-
- beforeEach(() => {
- const Component = Vue.extend(ideSidebar);
-
- store.state.currentProjectId = 'abcproject';
- store.state.projects.abcproject = projectData;
-
- vm = createComponentWithStore(Component, store).$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders a sidebar', () => {
- expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull();
- });
-
- it('renders loading icon component', done => {
- vm.$store.state.loading = true;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
- expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
-
- done();
- });
- });
-
- describe('activityBarComponent', () => {
- it('renders tree component', () => {
- expect(vm.$el.querySelector('.ide-file-list')).not.toBeNull();
- });
-
- it('renders commit component', done => {
- vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.multi-file-commit-panel-section')).not.toBeNull();
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js
deleted file mode 100644
index 4241b994cba..00000000000
--- a/spec/javascripts/ide/components/ide_spec.js
+++ /dev/null
@@ -1,125 +0,0 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import store from '~/ide/stores';
-import ide from '~/ide/components/ide.vue';
-import { file, resetStore } from '../helpers';
-import { projectData } from '../mock_data';
-
-function bootstrap(projData) {
- const Component = Vue.extend(ide);
-
- store.state.currentProjectId = 'abcproject';
- store.state.currentBranchId = 'master';
- store.state.projects.abcproject = Object.assign({}, projData);
- Vue.set(store.state.trees, 'abcproject/master', {
- tree: [],
- loading: false,
- });
-
- return createComponentWithStore(Component, store, {
- emptyStateSvgPath: 'svg',
- noChangesStateSvgPath: 'svg',
- committedStateSvgPath: 'svg',
- });
-}
-
-describe('ide component, empty repo', () => {
- let vm;
-
- beforeEach(() => {
- const emptyProjData = Object.assign({}, projectData, { empty_repo: true, branches: {} });
- vm = bootstrap(emptyProjData);
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders "New file" button in empty repo', done => {
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).not.toBeNull();
- done();
- });
- });
-});
-
-describe('ide component, non-empty repo', () => {
- let vm;
-
- beforeEach(() => {
- vm = bootstrap(projectData);
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('shows error message when set', done => {
- expect(vm.$el.querySelector('.gl-alert')).toBe(null);
-
- vm.$store.state.errorMessage = {
- text: 'error',
- };
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.gl-alert')).not.toBe(null);
-
- done();
- });
- });
-
- describe('onBeforeUnload', () => {
- it('returns undefined when no staged files or changed files', () => {
- expect(vm.onBeforeUnload()).toBe(undefined);
- });
-
- it('returns warning text when their are changed files', () => {
- vm.$store.state.changedFiles.push(file());
-
- expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?');
- });
-
- it('returns warning text when their are staged files', () => {
- vm.$store.state.stagedFiles.push(file());
-
- expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?');
- });
-
- it('updates event object', () => {
- const event = {};
- vm.$store.state.stagedFiles.push(file());
-
- vm.onBeforeUnload(event);
-
- expect(event.returnValue).toBe('Are you sure you want to lose unsaved changes?');
- });
- });
-
- describe('non-existent branch', () => {
- it('does not render "New file" button for non-existent branch when repo is not empty', done => {
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull();
- done();
- });
- });
- });
-
- describe('branch with files', () => {
- beforeEach(() => {
- store.state.trees['abcproject/master'].tree = [file()];
- });
-
- it('does not render "New file" button', done => {
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull();
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/ide/components/ide_status_bar_spec.js b/spec/javascripts/ide/components/ide_status_bar_spec.js
deleted file mode 100644
index 3facf1c266a..00000000000
--- a/spec/javascripts/ide/components/ide_status_bar_spec.js
+++ /dev/null
@@ -1,129 +0,0 @@
-import Vue from 'vue';
-import _ from 'lodash';
-import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import { TEST_HOST } from 'spec/test_constants';
-import { createStore } from '~/ide/stores';
-import IdeStatusBar from '~/ide/components/ide_status_bar.vue';
-import { rightSidebarViews } from '~/ide/constants';
-import { projectData } from '../mock_data';
-
-const TEST_PROJECT_ID = 'abcproject';
-const TEST_MERGE_REQUEST_ID = '9001';
-const TEST_MERGE_REQUEST_URL = `${TEST_HOST}merge-requests/${TEST_MERGE_REQUEST_ID}`;
-
-describe('ideStatusBar', () => {
- let store;
- let vm;
-
- const createComponent = () => {
- vm = createComponentWithStore(Vue.extend(IdeStatusBar), store).$mount();
- };
- const findMRStatus = () => vm.$el.querySelector('.js-ide-status-mr');
-
- beforeEach(() => {
- store = createStore();
- store.state.currentProjectId = TEST_PROJECT_ID;
- store.state.projects[TEST_PROJECT_ID] = _.clone(projectData);
- store.state.currentBranchId = 'master';
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('default', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('triggers a setInterval', () => {
- expect(vm.intervalId).not.toBe(null);
- });
-
- it('renders the statusbar', () => {
- expect(vm.$el.className).toBe('ide-status-bar');
- });
-
- describe('commitAgeUpdate', () => {
- beforeEach(function() {
- jasmine.clock().install();
- spyOn(vm, 'commitAgeUpdate').and.callFake(() => {});
- vm.startTimer();
- });
-
- afterEach(function() {
- jasmine.clock().uninstall();
- });
-
- it('gets called every second', () => {
- expect(vm.commitAgeUpdate).not.toHaveBeenCalled();
-
- jasmine.clock().tick(1100);
-
- expect(vm.commitAgeUpdate.calls.count()).toEqual(1);
-
- jasmine.clock().tick(1000);
-
- expect(vm.commitAgeUpdate.calls.count()).toEqual(2);
- });
- });
-
- describe('getCommitPath', () => {
- it('returns the path to the commit details', () => {
- expect(vm.getCommitPath('abc123de')).toBe('/commit/abc123de');
- });
- });
-
- describe('pipeline status', () => {
- it('opens right sidebar on clicking icon', done => {
- spyOn(vm, 'openRightPane');
- Vue.set(vm.$store.state.pipelines, 'latestPipeline', {
- details: {
- status: {
- text: 'success',
- details_path: 'test',
- icon: 'status_success',
- },
- },
- commit: {
- author_gravatar_url: 'www',
- },
- });
-
- vm.$nextTick()
- .then(() => {
- vm.$el.querySelector('.ide-status-pipeline button').click();
-
- expect(vm.openRightPane).toHaveBeenCalledWith(rightSidebarViews.pipelines);
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
- it('does not show merge request status', () => {
- expect(findMRStatus()).toBe(null);
- });
- });
-
- describe('with merge request in store', () => {
- beforeEach(() => {
- store.state.projects[TEST_PROJECT_ID].mergeRequests = {
- [TEST_MERGE_REQUEST_ID]: {
- web_url: TEST_MERGE_REQUEST_URL,
- references: {
- short: `!${TEST_MERGE_REQUEST_ID}`,
- },
- },
- };
- store.state.currentMergeRequestId = TEST_MERGE_REQUEST_ID;
-
- createComponent();
- });
-
- it('shows merge request status', () => {
- expect(findMRStatus().textContent.trim()).toEqual(`Merge request !${TEST_MERGE_REQUEST_ID}`);
- expect(findMRStatus().querySelector('a').href).toEqual(TEST_MERGE_REQUEST_URL);
- });
- });
-});
diff --git a/spec/javascripts/ide/components/ide_tree_list_spec.js b/spec/javascripts/ide/components/ide_tree_list_spec.js
deleted file mode 100644
index f63007c7dd2..00000000000
--- a/spec/javascripts/ide/components/ide_tree_list_spec.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import Vue from 'vue';
-import IdeTreeList from '~/ide/components/ide_tree_list.vue';
-import store from '~/ide/stores';
-import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
-import { resetStore, file } from '../helpers';
-import { projectData } from '../mock_data';
-
-describe('IDE tree list', () => {
- const Component = Vue.extend(IdeTreeList);
- const normalBranchTree = [file('fileName')];
- const emptyBranchTree = [];
- let vm;
-
- const bootstrapWithTree = (tree = normalBranchTree) => {
- store.state.currentProjectId = 'abcproject';
- store.state.currentBranchId = 'master';
- store.state.projects.abcproject = Object.assign({}, projectData);
- Vue.set(store.state.trees, 'abcproject/master', {
- tree,
- loading: false,
- });
-
- vm = createComponentWithStore(Component, store, {
- viewerType: 'edit',
- });
- };
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- describe('normal branch', () => {
- beforeEach(() => {
- bootstrapWithTree();
-
- spyOn(vm, 'updateViewer').and.callThrough();
-
- vm.$mount();
- });
-
- it('updates viewer on mount', () => {
- expect(vm.updateViewer).toHaveBeenCalledWith('edit');
- });
-
- it('renders loading indicator', done => {
- store.state.trees['abcproject/master'].loading = true;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
- expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
-
- done();
- });
- });
-
- it('renders list of files', () => {
- expect(vm.$el.textContent).toContain('fileName');
- });
- });
-
- describe('empty-branch state', () => {
- beforeEach(() => {
- bootstrapWithTree(emptyBranchTree);
-
- spyOn(vm, 'updateViewer').and.callThrough();
-
- vm.$mount();
- });
-
- it('does not load files if the branch is empty', () => {
- expect(vm.$el.textContent).not.toContain('fileName');
- expect(vm.$el.textContent).toContain('No files');
- });
- });
-});
diff --git a/spec/javascripts/ide/components/ide_tree_spec.js b/spec/javascripts/ide/components/ide_tree_spec.js
deleted file mode 100644
index 97a0a2432f1..00000000000
--- a/spec/javascripts/ide/components/ide_tree_spec.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import Vue from 'vue';
-import IdeTree from '~/ide/components/ide_tree.vue';
-import store from '~/ide/stores';
-import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
-import { resetStore, file } from '../helpers';
-import { projectData } from '../mock_data';
-
-describe('IdeRepoTree', () => {
- let vm;
-
- beforeEach(() => {
- const IdeRepoTree = Vue.extend(IdeTree);
-
- store.state.currentProjectId = 'abcproject';
- store.state.currentBranchId = 'master';
- store.state.projects.abcproject = Object.assign({}, projectData);
- Vue.set(store.state.trees, 'abcproject/master', {
- tree: [file('fileName')],
- loading: false,
- });
-
- vm = createComponentWithStore(IdeRepoTree, store).$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders list of files', () => {
- expect(vm.$el.textContent).toContain('fileName');
- });
-});
diff --git a/spec/javascripts/ide/components/merge_requests/item_spec.js b/spec/javascripts/ide/components/merge_requests/item_spec.js
deleted file mode 100644
index 155a247defb..00000000000
--- a/spec/javascripts/ide/components/merge_requests/item_spec.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import Vue from 'vue';
-import router from '~/ide/ide_router';
-import Item from '~/ide/components/merge_requests/item.vue';
-import mountCompontent from '../../../helpers/vue_mount_component_helper';
-
-describe('IDE merge request item', () => {
- const Component = Vue.extend(Item);
- let vm;
-
- beforeEach(() => {
- vm = mountCompontent(Component, {
- item: {
- iid: 1,
- projectPathWithNamespace: 'gitlab-org/gitlab-ce',
- title: 'Merge request title',
- },
- currentId: '1',
- currentProjectId: 'gitlab-org/gitlab-ce',
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders merge requests data', () => {
- expect(vm.$el.textContent).toContain('Merge request title');
- expect(vm.$el.textContent).toContain('gitlab-org/gitlab-ce!1');
- });
-
- it('renders link with href', () => {
- const expectedHref = router.resolve(
- `/project/${vm.item.projectPathWithNamespace}/merge_requests/${vm.item.iid}`,
- ).href;
-
- expect(vm.$el).toMatch('a');
- expect(vm.$el).toHaveAttr('href', expectedHref);
- });
-
- it('renders icon if ID matches currentId', () => {
- expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null);
- });
-
- it('does not render icon if ID does not match currentId', done => {
- vm.currentId = '2';
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null);
-
- done();
- });
- });
-
- it('does not render icon if project ID does not match', done => {
- vm.currentProjectId = 'test/test';
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null);
-
- done();
- });
- });
-});
diff --git a/spec/javascripts/ide/components/nav_dropdown_button_spec.js b/spec/javascripts/ide/components/nav_dropdown_button_spec.js
deleted file mode 100644
index bbaf97164ea..00000000000
--- a/spec/javascripts/ide/components/nav_dropdown_button_spec.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import Vue from 'vue';
-import { trimText } from 'spec/helpers/text_helper';
-import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import NavDropdownButton from '~/ide/components/nav_dropdown_button.vue';
-import { createStore } from '~/ide/stores';
-
-describe('NavDropdown', () => {
- const TEST_BRANCH_ID = 'lorem-ipsum-dolar';
- const TEST_MR_ID = '12345';
- let store;
- let vm;
-
- beforeEach(() => {
- store = createStore();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- const createComponent = (props = {}) => {
- vm = mountComponentWithStore(Vue.extend(NavDropdownButton), { props, store });
- vm.$mount();
- };
-
- const findIcon = name => vm.$el.querySelector(`.ic-${name}`);
- const findMRIcon = () => findIcon('merge-request');
- const findBranchIcon = () => findIcon('branch');
-
- describe('normal', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders empty placeholders, if state is falsey', () => {
- expect(trimText(vm.$el.textContent)).toEqual('- -');
- });
-
- it('renders branch name, if state has currentBranchId', done => {
- vm.$store.state.currentBranchId = TEST_BRANCH_ID;
-
- vm.$nextTick()
- .then(() => {
- expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} -`);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('renders mr id, if state has currentMergeRequestId', done => {
- vm.$store.state.currentMergeRequestId = TEST_MR_ID;
-
- vm.$nextTick()
- .then(() => {
- expect(trimText(vm.$el.textContent)).toEqual(`- !${TEST_MR_ID}`);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('renders branch and mr, if state has both', done => {
- vm.$store.state.currentBranchId = TEST_BRANCH_ID;
- vm.$store.state.currentMergeRequestId = TEST_MR_ID;
-
- vm.$nextTick()
- .then(() => {
- expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} !${TEST_MR_ID}`);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('shows icons', () => {
- expect(findBranchIcon()).toBeTruthy();
- expect(findMRIcon()).toBeTruthy();
- });
- });
-
- describe('with showMergeRequests false', () => {
- beforeEach(() => {
- createComponent({ showMergeRequests: false });
- });
-
- it('shows single empty placeholder, if state is falsey', () => {
- expect(trimText(vm.$el.textContent)).toEqual('-');
- });
-
- it('shows only branch icon', () => {
- expect(findBranchIcon()).toBeTruthy();
- expect(findMRIcon()).toBe(null);
- });
- });
-});
diff --git a/spec/javascripts/ide/components/nav_dropdown_spec.js b/spec/javascripts/ide/components/nav_dropdown_spec.js
deleted file mode 100644
index dfb4d03540f..00000000000
--- a/spec/javascripts/ide/components/nav_dropdown_spec.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import $ from 'jquery';
-import Vue from 'vue';
-import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import store from '~/ide/stores';
-import NavDropdown from '~/ide/components/nav_dropdown.vue';
-import { PERMISSION_READ_MR } from '~/ide/constants';
-
-const TEST_PROJECT_ID = 'lorem-ipsum';
-
-describe('IDE NavDropdown', () => {
- const Component = Vue.extend(NavDropdown);
- let vm;
- let $dropdown;
-
- beforeEach(() => {
- store.state.currentProjectId = TEST_PROJECT_ID;
- Vue.set(store.state.projects, TEST_PROJECT_ID, {
- userPermissions: {
- [PERMISSION_READ_MR]: true,
- },
- });
- vm = mountComponentWithStore(Component, { store });
- $dropdown = $(vm.$el);
-
- // block dispatch from doing anything
- spyOn(vm.$store, 'dispatch');
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- const findIcon = name => vm.$el.querySelector(`.ic-${name}`);
- const findMRIcon = () => findIcon('merge-request');
-
- it('renders nothing initially', () => {
- expect(vm.$el).not.toContainElement('.ide-nav-form');
- });
-
- it('renders nav form when show.bs.dropdown', done => {
- $dropdown.trigger('show.bs.dropdown');
-
- vm.$nextTick()
- .then(() => {
- expect(vm.$el).toContainElement('.ide-nav-form');
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('destroys nav form when closed', done => {
- $dropdown.trigger('show.bs.dropdown');
- $dropdown.trigger('hide.bs.dropdown');
-
- vm.$nextTick()
- .then(() => {
- expect(vm.$el).not.toContainElement('.ide-nav-form');
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('renders merge request icon', () => {
- expect(findMRIcon()).not.toBeNull();
- });
-
- describe('when user cannot read merge requests', () => {
- beforeEach(done => {
- store.state.projects[TEST_PROJECT_ID].userPermissions = {};
-
- vm.$nextTick()
- .then(done)
- .catch(done.fail);
- });
-
- it('does not render merge requests', () => {
- expect(findMRIcon()).toBeNull();
- });
- });
-});
diff --git a/spec/javascripts/ide/components/new_dropdown/button_spec.js b/spec/javascripts/ide/components/new_dropdown/button_spec.js
deleted file mode 100644
index 6a326b5bd92..00000000000
--- a/spec/javascripts/ide/components/new_dropdown/button_spec.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import Button from '~/ide/components/new_dropdown/button.vue';
-
-describe('IDE new entry dropdown button component', () => {
- let Component;
- let vm;
-
- beforeAll(() => {
- Component = Vue.extend(Button);
- });
-
- beforeEach(() => {
- vm = mountComponent(Component, {
- label: 'Testing',
- icon: 'doc-new',
- });
-
- spyOn(vm, '$emit');
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders button with label', () => {
- expect(vm.$el.textContent).toContain('Testing');
- });
-
- it('renders icon', () => {
- expect(vm.$el.querySelector('.ic-doc-new')).not.toBe(null);
- });
-
- it('emits click event', () => {
- vm.$el.click();
-
- expect(vm.$emit).toHaveBeenCalledWith('click');
- });
-
- it('hides label if showLabel is false', done => {
- vm.showLabel = false;
-
- vm.$nextTick(() => {
- expect(vm.$el.textContent).not.toContain('Testing');
-
- done();
- });
- });
-
- describe('tooltipTitle', () => {
- it('returns empty string when showLabel is true', () => {
- expect(vm.tooltipTitle).toBe('');
- });
-
- it('returns label', done => {
- vm.showLabel = false;
-
- vm.$nextTick(() => {
- expect(vm.tooltipTitle).toBe('Testing');
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/ide/components/new_dropdown/index_spec.js b/spec/javascripts/ide/components/new_dropdown/index_spec.js
deleted file mode 100644
index 03afe997fed..00000000000
--- a/spec/javascripts/ide/components/new_dropdown/index_spec.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import store from '~/ide/stores';
-import newDropdown from '~/ide/components/new_dropdown/index.vue';
-import { resetStore } from '../../helpers';
-
-describe('new dropdown component', () => {
- let vm;
-
- beforeEach(() => {
- const component = Vue.extend(newDropdown);
-
- vm = createComponentWithStore(component, store, {
- branch: 'master',
- path: '',
- mouseOver: false,
- type: 'tree',
- });
-
- vm.$store.state.currentProjectId = 'abcproject';
- vm.$store.state.path = '';
- vm.$store.state.trees['abcproject/mybranch'] = {
- tree: [],
- };
-
- spyOn(vm, 'openNewEntryModal');
-
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders new file, upload and new directory links', () => {
- const buttons = vm.$el.querySelectorAll('.dropdown-menu button');
-
- expect(buttons[0].textContent.trim()).toBe('New file');
- expect(buttons[1].textContent.trim()).toBe('Upload file');
- expect(buttons[2].textContent.trim()).toBe('New directory');
- });
-
- describe('createNewItem', () => {
- it('sets modalType to blob when new file is clicked', () => {
- vm.$el.querySelectorAll('.dropdown-menu button')[0].click();
-
- expect(vm.openNewEntryModal).toHaveBeenCalledWith({ type: 'blob', path: '' });
- });
-
- it('sets modalType to tree when new directory is clicked', () => {
- vm.$el.querySelectorAll('.dropdown-menu button')[2].click();
-
- expect(vm.openNewEntryModal).toHaveBeenCalledWith({ type: 'tree', path: '' });
- });
- });
-
- describe('isOpen', () => {
- it('scrolls dropdown into view', done => {
- spyOn(vm.$refs.dropdownMenu, 'scrollIntoView');
-
- vm.isOpen = true;
-
- setTimeout(() => {
- expect(vm.$refs.dropdownMenu.scrollIntoView).toHaveBeenCalledWith({
- block: 'nearest',
- });
-
- done();
- });
- });
- });
-
- describe('delete entry', () => {
- it('calls delete action', () => {
- spyOn(vm, 'deleteEntry');
-
- vm.$el.querySelectorAll('.dropdown-menu button')[4].click();
-
- expect(vm.deleteEntry).toHaveBeenCalledWith('');
- });
- });
-});
diff --git a/spec/javascripts/ide/components/new_dropdown/modal_spec.js b/spec/javascripts/ide/components/new_dropdown/modal_spec.js
deleted file mode 100644
index 0ea767e087d..00000000000
--- a/spec/javascripts/ide/components/new_dropdown/modal_spec.js
+++ /dev/null
@@ -1,150 +0,0 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import { createStore } from '~/ide/stores';
-import modal from '~/ide/components/new_dropdown/modal.vue';
-
-describe('new file modal component', () => {
- const Component = Vue.extend(modal);
- let vm;
-
- afterEach(() => {
- vm.$destroy();
- });
-
- ['tree', 'blob'].forEach(type => {
- describe(type, () => {
- beforeEach(() => {
- const store = createStore();
- store.state.entryModal = {
- type,
- path: '',
- entry: {
- path: '',
- },
- };
-
- vm = createComponentWithStore(Component, store).$mount();
-
- vm.name = 'testing';
- });
-
- it(`sets modal title as ${type}`, () => {
- const title = type === 'tree' ? 'directory' : 'file';
-
- expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe(`Create new ${title}`);
- });
-
- it(`sets button label as ${type}`, () => {
- const title = type === 'tree' ? 'directory' : 'file';
-
- expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe(`Create ${title}`);
- });
-
- it(`sets form label as ${type}`, () => {
- expect(vm.$el.querySelector('.label-bold').textContent.trim()).toBe('Name');
- });
-
- it(`${type === 'tree' ? 'does not show' : 'shows'} file templates`, () => {
- const templateFilesEl = vm.$el.querySelector('.file-templates');
- if (type === 'tree') {
- expect(templateFilesEl).toBeNull();
- } else {
- expect(templateFilesEl instanceof Element).toBeTruthy();
- }
- });
- });
- });
-
- describe('rename entry', () => {
- beforeEach(() => {
- const store = createStore();
- store.state.entryModal = {
- type: 'rename',
- path: '',
- entry: {
- name: 'test',
- type: 'blob',
- path: 'test-path',
- },
- };
-
- vm = createComponentWithStore(Component, store).$mount();
- });
-
- ['tree', 'blob'].forEach(type => {
- it(`renders title and button for renaming ${type}`, done => {
- const text = type === 'tree' ? 'folder' : 'file';
-
- vm.$store.state.entryModal.entry.type = type;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe(`Rename ${text}`);
- expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe(`Rename ${text}`);
-
- done();
- });
- });
- });
-
- describe('entryName', () => {
- it('returns entries name', () => {
- expect(vm.entryName).toBe('test-path');
- });
-
- it('updated name', () => {
- vm.name = 'index.js';
-
- expect(vm.entryName).toBe('index.js');
- });
-
- it('removes leading/trailing spaces when found in the new name', () => {
- vm.entryName = ' index.js ';
-
- expect(vm.entryName).toBe('index.js');
- });
-
- it('does not remove internal spaces in the file name', () => {
- vm.entryName = ' In Praise of Idleness.txt ';
-
- expect(vm.entryName).toBe('In Praise of Idleness.txt');
- });
- });
- });
-
- describe('submitForm', () => {
- it('throws an error when target entry exists', () => {
- const store = createStore();
- store.state.entryModal = {
- type: 'rename',
- path: 'test-path/test',
- entry: {
- name: 'test',
- type: 'blob',
- path: 'test-path/test',
- },
- };
- store.state.entries = {
- 'test-path/test': {
- name: 'test',
- deleted: false,
- },
- };
-
- vm = createComponentWithStore(Component, store).$mount();
- const flashSpy = spyOnDependency(modal, 'flash');
-
- expect(flashSpy).not.toHaveBeenCalled();
-
- vm.submitForm();
-
- expect(flashSpy).toHaveBeenCalledWith(
- 'The name "test-path/test" is already taken in this directory.',
- 'alert',
- jasmine.anything(),
- null,
- false,
- true,
- );
- });
- });
-});
diff --git a/spec/javascripts/ide/components/new_dropdown/upload_spec.js b/spec/javascripts/ide/components/new_dropdown/upload_spec.js
deleted file mode 100644
index 66ddf6c0ee6..00000000000
--- a/spec/javascripts/ide/components/new_dropdown/upload_spec.js
+++ /dev/null
@@ -1,112 +0,0 @@
-import Vue from 'vue';
-import createComponent from 'spec/helpers/vue_mount_component_helper';
-import upload from '~/ide/components/new_dropdown/upload.vue';
-
-describe('new dropdown upload', () => {
- let vm;
-
- beforeEach(() => {
- const Component = Vue.extend(upload);
-
- vm = createComponent(Component, {
- path: '',
- });
-
- vm.entryName = 'testing';
-
- spyOn(vm, '$emit').and.callThrough();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('openFile', () => {
- it('calls for each file', () => {
- const files = ['test', 'test2', 'test3'];
-
- spyOn(vm, 'readFile');
- spyOnProperty(vm.$refs.fileUpload, 'files').and.returnValue(files);
-
- vm.openFile();
-
- expect(vm.readFile.calls.count()).toBe(3);
-
- files.forEach((file, i) => {
- expect(vm.readFile.calls.argsFor(i)).toEqual([file]);
- });
- });
- });
-
- describe('readFile', () => {
- beforeEach(() => {
- spyOn(FileReader.prototype, 'readAsDataURL');
- });
-
- it('calls readAsDataURL for all files', () => {
- const file = {
- type: 'images/png',
- };
-
- vm.readFile(file);
-
- expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file);
- });
- });
-
- describe('createFile', () => {
- const textTarget = {
- result: 'base64,cGxhaW4gdGV4dA==',
- };
- const binaryTarget = {
- result: 'base64,w4I=',
- };
- const textFile = new File(['plain text'], 'textFile');
-
- const binaryFile = {
- name: 'binaryFile',
- type: 'image/png',
- };
-
- beforeEach(() => {
- spyOn(FileReader.prototype, 'readAsText').and.callThrough();
- });
-
- it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', done => {
- const waitForCreate = new Promise(resolve => vm.$on('create', resolve));
-
- vm.createFile(textTarget, textFile);
-
- expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(textFile);
-
- waitForCreate
- .then(() => {
- expect(vm.$emit).toHaveBeenCalledWith('create', {
- name: textFile.name,
- type: 'blob',
- content: 'plain text',
- base64: false,
- binary: false,
- rawPath: '',
- });
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('splits content on base64 if binary', () => {
- vm.createFile(binaryTarget, binaryFile);
-
- expect(FileReader.prototype.readAsText).not.toHaveBeenCalledWith(textFile);
-
- expect(vm.$emit).toHaveBeenCalledWith('create', {
- name: binaryFile.name,
- type: 'blob',
- content: binaryTarget.result.split('base64,')[1],
- base64: true,
- binary: true,
- rawPath: binaryTarget.result,
- });
- });
- });
-});
diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js
index ef0299f0d56..8db29011da7 100644
--- a/spec/javascripts/ide/components/repo_editor_spec.js
+++ b/spec/javascripts/ide/components/repo_editor_spec.js
@@ -26,7 +26,23 @@ describe('RepoEditor', () => {
f.active = true;
f.tempFile = true;
+
vm.$store.state.openFiles.push(f);
+ vm.$store.state.projects = {
+ 'gitlab-org/gitlab': {
+ branches: {
+ master: {
+ name: 'master',
+ commit: {
+ id: 'abcdefgh',
+ },
+ },
+ },
+ },
+ };
+ vm.$store.state.currentProjectId = 'gitlab-org/gitlab';
+ vm.$store.state.currentBranchId = 'master';
+
Vue.set(vm.$store.state.entries, f.path, f);
spyOn(vm, 'getFileData').and.returnValue(Promise.resolve());
@@ -46,11 +62,6 @@ describe('RepoEditor', () => {
});
const findEditor = () => vm.$el.querySelector('.multi-file-editor-holder');
- const changeRightPanelCollapsed = () => {
- const { state } = vm.$store;
-
- state.rightPanelCollapsed = !state.rightPanelCollapsed;
- };
it('sets renderWhitespace to `all`', () => {
vm.$store.state.renderWhitespaceInCode = true;
@@ -303,17 +314,6 @@ describe('RepoEditor', () => {
spyOn(vm.editor, 'updateDiffView');
});
- it('calls updateDimensions when rightPanelCollapsed is changed', done => {
- changeRightPanelCollapsed();
-
- vm.$nextTick(() => {
- expect(vm.editor.updateDimensions).toHaveBeenCalled();
- expect(vm.editor.updateDiffView).toHaveBeenCalled();
-
- done();
- });
- });
-
it('calls updateDimensions when panelResizing is false', done => {
vm.$store.state.panelResizing = true;
@@ -391,17 +391,6 @@ describe('RepoEditor', () => {
expect(findEditor()).toHaveCss({ display: 'none' });
});
- it('should not update dimensions', done => {
- changeRightPanelCollapsed();
-
- vm.$nextTick()
- .then(() => {
- expect(vm.editor.updateDimensions).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
-
describe('when file view mode changes to editor', () => {
beforeEach(done => {
vm.file.viewMode = FILE_VIEW_MODE_EDITOR;
diff --git a/spec/javascripts/ide/components/repo_tab_spec.js b/spec/javascripts/ide/components/repo_tab_spec.js
deleted file mode 100644
index 3b52f279bf2..00000000000
--- a/spec/javascripts/ide/components/repo_tab_spec.js
+++ /dev/null
@@ -1,185 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import repoTab from '~/ide/components/repo_tab.vue';
-import router from '~/ide/ide_router';
-import { file, resetStore } from '../helpers';
-
-describe('RepoTab', () => {
- let vm;
-
- function createComponent(propsData) {
- const RepoTab = Vue.extend(repoTab);
-
- return new RepoTab({
- store,
- propsData,
- }).$mount();
- }
-
- beforeEach(() => {
- spyOn(router, 'push');
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders a close link and a name link', () => {
- vm = 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}"]`);
-
- expect(close.innerHTML).toContain('#close');
- expect(name.textContent.trim()).toEqual(vm.tab.name);
- });
-
- it('does not call openPendingTab when tab is active', done => {
- vm = createComponent({
- tab: {
- ...file(),
- pending: true,
- active: true,
- },
- });
-
- spyOn(vm, 'openPendingTab');
-
- vm.$el.click();
-
- vm.$nextTick(() => {
- expect(vm.openPendingTab).not.toHaveBeenCalled();
-
- done();
- });
- });
-
- it('fires clickFile when the link is clicked', () => {
- vm = createComponent({
- tab: file(),
- });
-
- spyOn(vm, 'clickFile');
-
- vm.$el.click();
-
- expect(vm.clickFile).toHaveBeenCalledWith(vm.tab);
- });
-
- it('calls closeFile when clicking close button', () => {
- vm = createComponent({
- tab: file(),
- });
-
- spyOn(vm, 'closeFile');
-
- vm.$el.querySelector('.multi-file-tab-close').click();
-
- expect(vm.closeFile).toHaveBeenCalledWith(vm.tab);
- });
-
- it('changes icon on hover', done => {
- const tab = file();
- tab.changed = true;
- vm = createComponent({
- tab,
- });
-
- vm.$el.dispatchEvent(new Event('mouseover'));
-
- Vue.nextTick()
- .then(() => {
- expect(vm.$el.querySelector('.file-modified')).toBeNull();
-
- vm.$el.dispatchEvent(new Event('mouseout'));
- })
- .then(Vue.nextTick)
- .then(() => {
- expect(vm.$el.querySelector('.file-modified')).not.toBeNull();
-
- done();
- })
- .catch(done.fail);
- });
-
- describe('locked file', () => {
- let f;
-
- beforeEach(() => {
- f = file('locked file');
- f.file_lock = {
- user: {
- name: 'testuser',
- updated_at: new Date(),
- },
- };
-
- vm = createComponent({
- tab: f,
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders lock icon', () => {
- expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull();
- });
-
- it('renders a tooltip', () => {
- expect(vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle).toContain(
- 'Locked by testuser',
- );
- });
- });
-
- describe('methods', () => {
- describe('closeTab', () => {
- it('closes tab if file has changed', done => {
- const tab = file();
- tab.changed = true;
- tab.opened = true;
- vm = 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();
-
- vm.$nextTick(() => {
- expect(tab.opened).toBeFalsy();
- expect(vm.$store.state.changedFiles.length).toBe(1);
-
- done();
- });
- });
-
- it('closes tab when clicking close btn', done => {
- const tab = file('lose');
- tab.opened = true;
- vm = createComponent({
- tab,
- });
- vm.$store.state.openFiles.push(tab);
- vm.$store.state.entries[tab.path] = tab;
- vm.$store.dispatch('setFileActive', tab.path);
-
- vm.$el.querySelector('.multi-file-tab-close').click();
-
- vm.$nextTick(() => {
- expect(tab.opened).toBeFalsy();
-
- done();
- });
- });
- });
- });
-});
diff --git a/spec/javascripts/ide/components/shared/tokened_input_spec.js b/spec/javascripts/ide/components/shared/tokened_input_spec.js
deleted file mode 100644
index 885fd976655..00000000000
--- a/spec/javascripts/ide/components/shared/tokened_input_spec.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import TokenedInput from '~/ide/components/shared/tokened_input.vue';
-
-const TEST_PLACEHOLDER = 'Searching in test';
-const TEST_TOKENS = [
- { label: 'lorem', id: 1 },
- { label: 'ipsum', id: 2 },
- { label: 'dolar', id: 3 },
-];
-const TEST_VALUE = 'lorem';
-
-function getTokenElements(vm) {
- return Array.from(vm.$el.querySelectorAll('.filtered-search-token button'));
-}
-
-function createBackspaceEvent() {
- const e = new Event('keyup');
- e.keyCode = 8;
- e.which = e.keyCode;
- e.altKey = false;
- e.ctrlKey = true;
- e.shiftKey = false;
- e.metaKey = false;
- return e;
-}
-
-describe('IDE shared/TokenedInput', () => {
- const Component = Vue.extend(TokenedInput);
- let vm;
-
- beforeEach(() => {
- vm = mountComponent(Component, {
- tokens: TEST_TOKENS,
- placeholder: TEST_PLACEHOLDER,
- value: TEST_VALUE,
- });
-
- spyOn(vm, '$emit');
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders tokens', () => {
- const renderedTokens = getTokenElements(vm).map(x => x.textContent.trim());
-
- expect(renderedTokens).toEqual(TEST_TOKENS.map(x => x.label));
- });
-
- it('renders input', () => {
- expect(vm.$refs.input).toBeTruthy();
- expect(vm.$refs.input).toHaveValue(TEST_VALUE);
- });
-
- it('renders placeholder, when tokens are empty', done => {
- vm.tokens = [];
-
- vm.$nextTick()
- .then(() => {
- expect(vm.$refs.input).toHaveAttr('placeholder', TEST_PLACEHOLDER);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('triggers "removeToken" on token click', () => {
- getTokenElements(vm)[0].click();
-
- expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[0]);
- });
-
- it('when input triggers backspace event, it calls "onBackspace"', () => {
- spyOn(vm, 'onBackspace');
-
- vm.$refs.input.dispatchEvent(createBackspaceEvent());
- vm.$refs.input.dispatchEvent(createBackspaceEvent());
-
- expect(vm.onBackspace).toHaveBeenCalledTimes(2);
- });
-
- it('triggers "removeToken" on backspaces when value is empty', () => {
- vm.value = '';
-
- vm.onBackspace();
-
- expect(vm.$emit).not.toHaveBeenCalled();
- expect(vm.backspaceCount).toEqual(1);
-
- vm.onBackspace();
-
- expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[TEST_TOKENS.length - 1]);
- expect(vm.backspaceCount).toEqual(0);
- });
-
- it('does not trigger "removeToken" on backspaces when value is not empty', () => {
- vm.onBackspace();
- vm.onBackspace();
-
- expect(vm.backspaceCount).toEqual(0);
- expect(vm.$emit).not.toHaveBeenCalled();
- });
-
- it('does not trigger "removeToken" on backspaces when tokens are empty', () => {
- vm.tokens = [];
-
- vm.onBackspace();
- vm.onBackspace();
-
- expect(vm.backspaceCount).toEqual(0);
- expect(vm.$emit).not.toHaveBeenCalled();
- });
-
- it('triggers "focus" on input focus', () => {
- vm.$refs.input.dispatchEvent(new Event('focus'));
-
- expect(vm.$emit).toHaveBeenCalledWith('focus');
- });
-
- it('triggers "blur" on input blur', () => {
- vm.$refs.input.dispatchEvent(new Event('blur'));
-
- expect(vm.$emit).toHaveBeenCalledWith('blur');
- });
-
- it('triggers "input" with value on input change', () => {
- vm.$refs.input.value = 'something-else';
- vm.$refs.input.dispatchEvent(new Event('input'));
-
- expect(vm.$emit).toHaveBeenCalledWith('input', 'something-else');
- });
-});
diff --git a/spec/javascripts/ide/lib/common/model_manager_spec.js b/spec/javascripts/ide/lib/common/model_manager_spec.js
deleted file mode 100644
index 38ffa317e8e..00000000000
--- a/spec/javascripts/ide/lib/common/model_manager_spec.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import eventHub from '~/ide/eventhub';
-import ModelManager from '~/ide/lib/common/model_manager';
-import { file } from '../../helpers';
-
-describe('Multi-file editor library model manager', () => {
- let instance;
-
- beforeEach(() => {
- instance = new ModelManager();
- });
-
- afterEach(() => {
- instance.dispose();
- });
-
- describe('addModel', () => {
- it('caches model', () => {
- instance.addModel(file());
-
- expect(instance.models.size).toBe(1);
- });
-
- it('caches model by file path', () => {
- const f = file('path-name');
- instance.addModel(f);
-
- expect(instance.models.keys().next().value).toBe(f.key);
- });
-
- it('adds model into disposable', () => {
- spyOn(instance.disposable, 'add').and.callThrough();
-
- instance.addModel(file());
-
- expect(instance.disposable.add).toHaveBeenCalled();
- });
-
- it('returns cached model', () => {
- spyOn(instance.models, 'get').and.callThrough();
-
- instance.addModel(file());
- instance.addModel(file());
-
- expect(instance.models.get).toHaveBeenCalled();
- });
-
- it('adds eventHub listener', () => {
- const f = file();
- spyOn(eventHub, '$on').and.callThrough();
-
- instance.addModel(f);
-
- expect(eventHub.$on).toHaveBeenCalledWith(
- `editor.update.model.dispose.${f.key}`,
- jasmine.anything(),
- );
- });
- });
-
- describe('hasCachedModel', () => {
- it('returns false when no models exist', () => {
- expect(instance.hasCachedModel('path')).toBeFalsy();
- });
-
- it('returns true when model exists', () => {
- const f = file('path-name');
-
- instance.addModel(f);
-
- expect(instance.hasCachedModel(f.key)).toBeTruthy();
- });
- });
-
- describe('getModel', () => {
- it('returns cached model', () => {
- instance.addModel(file('path-name'));
-
- expect(instance.getModel('path-name')).not.toBeNull();
- });
- });
-
- describe('removeCachedModel', () => {
- let f;
-
- beforeEach(() => {
- f = file();
-
- instance.addModel(f);
- });
-
- it('clears cached model', () => {
- instance.removeCachedModel(f);
-
- expect(instance.models.size).toBe(0);
- });
-
- it('removes eventHub listener', () => {
- spyOn(eventHub, '$off').and.callThrough();
-
- instance.removeCachedModel(f);
-
- expect(eventHub.$off).toHaveBeenCalledWith(
- `editor.update.model.dispose.${f.key}`,
- jasmine.anything(),
- );
- });
- });
-
- describe('dispose', () => {
- it('clears cached models', () => {
- instance.addModel(file());
-
- instance.dispose();
-
- expect(instance.models.size).toBe(0);
- });
-
- it('calls disposable dispose', () => {
- spyOn(instance.disposable, 'dispose').and.callThrough();
-
- instance.dispose();
-
- expect(instance.disposable.dispose).toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/javascripts/ide/lib/common/model_spec.js b/spec/javascripts/ide/lib/common/model_spec.js
deleted file mode 100644
index f096e06f43c..00000000000
--- a/spec/javascripts/ide/lib/common/model_spec.js
+++ /dev/null
@@ -1,137 +0,0 @@
-import eventHub from '~/ide/eventhub';
-import Model from '~/ide/lib/common/model';
-import { file } from '../../helpers';
-
-describe('Multi-file editor library model', () => {
- let model;
-
- beforeEach(() => {
- spyOn(eventHub, '$on').and.callThrough();
-
- const f = file('path');
- f.mrChange = { diff: 'ABC' };
- f.baseRaw = 'test';
- model = new Model(f);
- });
-
- afterEach(() => {
- model.dispose();
- });
-
- it('creates original model & base model & new model', () => {
- expect(model.originalModel).not.toBeNull();
- expect(model.model).not.toBeNull();
- expect(model.baseModel).not.toBeNull();
-
- expect(model.originalModel.uri.path).toBe('original/path--path');
- expect(model.model.uri.path).toBe('path--path');
- expect(model.baseModel.uri.path).toBe('target/path--path');
- });
-
- it('creates model with head file to compare against', () => {
- const f = file('path');
- model.dispose();
-
- model = new Model(f, {
- ...f,
- content: '123 testing',
- });
-
- expect(model.head).not.toBeNull();
- expect(model.getOriginalModel().getValue()).toBe('123 testing');
- });
-
- it('adds eventHub listener', () => {
- expect(eventHub.$on).toHaveBeenCalledWith(
- `editor.update.model.dispose.${model.file.key}`,
- jasmine.anything(),
- );
- });
-
- describe('path', () => {
- it('returns file path', () => {
- expect(model.path).toBe(model.file.key);
- });
- });
-
- describe('getModel', () => {
- it('returns model', () => {
- expect(model.getModel()).toBe(model.model);
- });
- });
-
- describe('getOriginalModel', () => {
- it('returns original model', () => {
- expect(model.getOriginalModel()).toBe(model.originalModel);
- });
- });
-
- describe('getBaseModel', () => {
- it('returns base model', () => {
- expect(model.getBaseModel()).toBe(model.baseModel);
- });
- });
-
- describe('setValue', () => {
- it('updates models value', () => {
- model.setValue('testing 123');
-
- expect(model.getModel().getValue()).toBe('testing 123');
- });
- });
-
- describe('onChange', () => {
- it('calls callback on change', done => {
- const spy = jasmine.createSpy();
- model.onChange(spy);
-
- model.getModel().setValue('123');
-
- setTimeout(() => {
- expect(spy).toHaveBeenCalledWith(model, jasmine.anything());
- done();
- });
- });
- });
-
- describe('dispose', () => {
- it('calls disposable dispose', () => {
- spyOn(model.disposable, 'dispose').and.callThrough();
-
- model.dispose();
-
- expect(model.disposable.dispose).toHaveBeenCalled();
- });
-
- it('clears events', () => {
- model.onChange(() => {});
-
- expect(model.events.size).toBe(1);
-
- model.dispose();
-
- expect(model.events.size).toBe(0);
- });
-
- it('removes eventHub listener', () => {
- spyOn(eventHub, '$off').and.callThrough();
-
- model.dispose();
-
- expect(eventHub.$off).toHaveBeenCalledWith(
- `editor.update.model.dispose.${model.file.key}`,
- jasmine.anything(),
- );
- });
-
- it('calls onDispose callback', () => {
- const disposeSpy = jasmine.createSpy();
-
- model.onDispose(disposeSpy);
-
- model.dispose();
-
- expect(disposeSpy).toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/javascripts/ide/lib/decorations/controller_spec.js b/spec/javascripts/ide/lib/decorations/controller_spec.js
deleted file mode 100644
index 4118774cca3..00000000000
--- a/spec/javascripts/ide/lib/decorations/controller_spec.js
+++ /dev/null
@@ -1,143 +0,0 @@
-import Editor from '~/ide/lib/editor';
-import DecorationsController from '~/ide/lib/decorations/controller';
-import Model from '~/ide/lib/common/model';
-import { file } from '../../helpers';
-
-describe('Multi-file editor library decorations controller', () => {
- let editorInstance;
- let controller;
- let model;
-
- beforeEach(() => {
- editorInstance = Editor.create();
- editorInstance.createInstance(document.createElement('div'));
-
- controller = new DecorationsController(editorInstance);
- model = new Model(file('path'));
- });
-
- afterEach(() => {
- model.dispose();
- editorInstance.dispose();
- controller.dispose();
- });
-
- describe('getAllDecorationsForModel', () => {
- it('returns empty array when no decorations exist for model', () => {
- const decorations = controller.getAllDecorationsForModel(model);
-
- expect(decorations).toEqual([]);
- });
-
- it('returns decorations by model URL', () => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
-
- const decorations = controller.getAllDecorationsForModel(model);
-
- expect(decorations[0]).toEqual({ decoration: 'decorationValue' });
- });
- });
-
- describe('addDecorations', () => {
- it('caches decorations in a new map', () => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
-
- expect(controller.decorations.size).toBe(1);
- });
-
- it('does not create new cache model', () => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]);
-
- expect(controller.decorations.size).toBe(1);
- });
-
- it('caches decorations by model URL', () => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
-
- expect(controller.decorations.size).toBe(1);
- expect(controller.decorations.keys().next().value).toBe('gitlab:path--path');
- });
-
- it('calls decorate method', () => {
- spyOn(controller, 'decorate');
-
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
-
- expect(controller.decorate).toHaveBeenCalled();
- });
- });
-
- describe('decorate', () => {
- it('sets decorations on editor instance', () => {
- spyOn(controller.editor.instance, 'deltaDecorations');
-
- controller.decorate(model);
-
- expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []);
- });
-
- it('caches decorations', () => {
- spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]);
-
- controller.decorate(model);
-
- expect(controller.editorDecorations.size).toBe(1);
- });
-
- it('caches decorations by model URL', () => {
- spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]);
-
- controller.decorate(model);
-
- expect(controller.editorDecorations.keys().next().value).toBe('gitlab:path--path');
- });
- });
-
- describe('dispose', () => {
- it('clears cached decorations', () => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
-
- controller.dispose();
-
- expect(controller.decorations.size).toBe(0);
- });
-
- it('clears cached editorDecorations', () => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
-
- controller.dispose();
-
- expect(controller.editorDecorations.size).toBe(0);
- });
- });
-
- describe('hasDecorations', () => {
- it('returns true when decorations are cached', () => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
-
- expect(controller.hasDecorations(model)).toBe(true);
- });
-
- it('returns false when no model decorations exist', () => {
- expect(controller.hasDecorations(model)).toBe(false);
- });
- });
-
- describe('removeDecorations', () => {
- beforeEach(() => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
- controller.decorate(model);
- });
-
- it('removes cached decorations', () => {
- expect(controller.decorations.size).not.toBe(0);
- expect(controller.editorDecorations.size).not.toBe(0);
-
- controller.removeDecorations(model);
-
- expect(controller.decorations.size).toBe(0);
- expect(controller.editorDecorations.size).toBe(0);
- });
- });
-});
diff --git a/spec/javascripts/ide/lib/diff/controller_spec.js b/spec/javascripts/ide/lib/diff/controller_spec.js
deleted file mode 100644
index 90ebb95b687..00000000000
--- a/spec/javascripts/ide/lib/diff/controller_spec.js
+++ /dev/null
@@ -1,215 +0,0 @@
-import { Range } from 'monaco-editor';
-import Editor from '~/ide/lib/editor';
-import ModelManager from '~/ide/lib/common/model_manager';
-import DecorationsController from '~/ide/lib/decorations/controller';
-import DirtyDiffController, { getDiffChangeType, getDecorator } from '~/ide/lib/diff/controller';
-import { computeDiff } from '~/ide/lib/diff/diff';
-import { file } from '../../helpers';
-
-describe('Multi-file editor library dirty diff controller', () => {
- let editorInstance;
- let controller;
- let modelManager;
- let decorationsController;
- let model;
-
- beforeEach(() => {
- editorInstance = Editor.create();
- editorInstance.createInstance(document.createElement('div'));
-
- modelManager = new ModelManager();
- decorationsController = new DecorationsController(editorInstance);
-
- model = modelManager.addModel(file('path'));
-
- controller = new DirtyDiffController(modelManager, decorationsController);
- });
-
- afterEach(() => {
- controller.dispose();
- model.dispose();
- decorationsController.dispose();
- editorInstance.dispose();
- });
-
- describe('getDiffChangeType', () => {
- ['added', 'removed', 'modified'].forEach(type => {
- it(`returns ${type}`, () => {
- const change = {
- [type]: true,
- };
-
- expect(getDiffChangeType(change)).toBe(type);
- });
- });
- });
-
- describe('getDecorator', () => {
- ['added', 'removed', 'modified'].forEach(type => {
- it(`returns with linesDecorationsClassName for ${type}`, () => {
- const change = {
- [type]: true,
- };
-
- expect(getDecorator(change).options.linesDecorationsClassName).toBe(
- `dirty-diff dirty-diff-${type}`,
- );
- });
-
- it('returns with line numbers', () => {
- const change = {
- lineNumber: 1,
- endLineNumber: 2,
- [type]: true,
- };
-
- const { range } = getDecorator(change);
-
- expect(range.startLineNumber).toBe(1);
- expect(range.endLineNumber).toBe(2);
- expect(range.startColumn).toBe(1);
- expect(range.endColumn).toBe(1);
- });
- });
- });
-
- describe('attachModel', () => {
- it('adds change event callback', () => {
- spyOn(model, 'onChange');
-
- controller.attachModel(model);
-
- expect(model.onChange).toHaveBeenCalled();
- });
-
- it('adds dispose event callback', () => {
- spyOn(model, 'onDispose');
-
- controller.attachModel(model);
-
- expect(model.onDispose).toHaveBeenCalled();
- });
-
- it('calls throttledComputeDiff on change', () => {
- spyOn(controller, 'throttledComputeDiff');
-
- controller.attachModel(model);
-
- model.getModel().setValue('123');
-
- expect(controller.throttledComputeDiff).toHaveBeenCalled();
- });
-
- it('caches model', () => {
- controller.attachModel(model);
-
- expect(controller.models.has(model.url)).toBe(true);
- });
- });
-
- describe('computeDiff', () => {
- it('posts to worker', () => {
- spyOn(controller.dirtyDiffWorker, 'postMessage');
-
- controller.computeDiff(model);
-
- expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({
- path: model.path,
- originalContent: '',
- newContent: '',
- });
- });
- });
-
- describe('reDecorate', () => {
- it('calls computeDiff when no decorations are cached', () => {
- spyOn(controller, 'computeDiff');
-
- controller.reDecorate(model);
-
- expect(controller.computeDiff).toHaveBeenCalledWith(model);
- });
-
- it('calls decorate when decorations are cached', () => {
- spyOn(controller.decorationsController, 'decorate');
-
- controller.decorationsController.decorations.set(model.url, 'test');
-
- controller.reDecorate(model);
-
- expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model);
- });
- });
-
- describe('decorate', () => {
- it('adds decorations into decorations controller', () => {
- spyOn(controller.decorationsController, 'addDecorations');
-
- controller.decorate({ data: { changes: [], path: model.path } });
-
- expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith(
- model,
- 'dirtyDiff',
- jasmine.anything(),
- );
- });
-
- it('adds decorations into editor', () => {
- const spy = spyOn(controller.decorationsController.editor.instance, 'deltaDecorations');
-
- controller.decorate({
- data: { changes: computeDiff('123', '1234'), path: model.path },
- });
-
- expect(spy).toHaveBeenCalledWith(
- [],
- [
- {
- range: new Range(1, 1, 1, 1),
- options: {
- isWholeLine: true,
- linesDecorationsClassName: 'dirty-diff dirty-diff-modified',
- },
- },
- ],
- );
- });
- });
-
- describe('dispose', () => {
- it('calls disposable dispose', () => {
- spyOn(controller.disposable, 'dispose').and.callThrough();
-
- controller.dispose();
-
- expect(controller.disposable.dispose).toHaveBeenCalled();
- });
-
- it('terminates worker', () => {
- spyOn(controller.dirtyDiffWorker, 'terminate').and.callThrough();
-
- controller.dispose();
-
- expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled();
- });
-
- it('removes worker event listener', () => {
- spyOn(controller.dirtyDiffWorker, 'removeEventListener').and.callThrough();
-
- controller.dispose();
-
- expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith(
- 'message',
- jasmine.anything(),
- );
- });
-
- it('clears cached models', () => {
- controller.attachModel(model);
-
- model.dispose();
-
- expect(controller.models.size).toBe(0);
- });
- });
-});
diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js
deleted file mode 100644
index 556bd45d3a5..00000000000
--- a/spec/javascripts/ide/lib/editor_spec.js
+++ /dev/null
@@ -1,287 +0,0 @@
-import { editor as monacoEditor } from 'monaco-editor';
-import Editor from '~/ide/lib/editor';
-import { file } from '../helpers';
-
-describe('Multi-file editor library', () => {
- let instance;
- let el;
- let holder;
-
- beforeEach(() => {
- el = document.createElement('div');
- holder = document.createElement('div');
- el.appendChild(holder);
-
- document.body.appendChild(el);
-
- instance = Editor.create();
- });
-
- afterEach(() => {
- instance.dispose();
-
- el.remove();
- });
-
- it('creates instance of editor', () => {
- expect(Editor.editorInstance).not.toBeNull();
- });
-
- it('creates instance returns cached instance', () => {
- expect(Editor.create()).toEqual(instance);
- });
-
- describe('createInstance', () => {
- it('creates editor instance', () => {
- spyOn(monacoEditor, 'create').and.callThrough();
-
- instance.createInstance(holder);
-
- expect(monacoEditor.create).toHaveBeenCalled();
- });
-
- it('creates dirty diff controller', () => {
- instance.createInstance(holder);
-
- expect(instance.dirtyDiffController).not.toBeNull();
- });
-
- it('creates model manager', () => {
- instance.createInstance(holder);
-
- expect(instance.modelManager).not.toBeNull();
- });
- });
-
- describe('createDiffInstance', () => {
- it('creates editor instance', () => {
- spyOn(monacoEditor, 'createDiffEditor').and.callThrough();
-
- instance.createDiffInstance(holder);
-
- expect(monacoEditor.createDiffEditor).toHaveBeenCalledWith(holder, {
- model: null,
- contextmenu: true,
- minimap: {
- enabled: false,
- },
- readOnly: true,
- scrollBeyondLastLine: false,
- renderWhitespace: 'none',
- quickSuggestions: false,
- occurrencesHighlight: false,
- wordWrap: 'on',
- renderSideBySide: true,
- renderLineHighlight: 'all',
- hideCursorInOverviewRuler: false,
- theme: 'vs white',
- });
- });
- });
-
- describe('createModel', () => {
- it('calls model manager addModel', () => {
- spyOn(instance.modelManager, 'addModel');
-
- instance.createModel('FILE');
-
- expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE', null);
- });
- });
-
- describe('attachModel', () => {
- let model;
-
- beforeEach(() => {
- instance.createInstance(document.createElement('div'));
-
- model = instance.createModel(file());
- });
-
- it('sets the current model on the instance', () => {
- instance.attachModel(model);
-
- expect(instance.currentModel).toBe(model);
- });
-
- it('attaches the model to the current instance', () => {
- spyOn(instance.instance, 'setModel');
-
- instance.attachModel(model);
-
- expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel());
- });
-
- it('sets original & modified when diff editor', () => {
- spyOn(instance.instance, 'getEditorType').and.returnValue('vs.editor.IDiffEditor');
- spyOn(instance.instance, 'setModel');
-
- instance.attachModel(model);
-
- expect(instance.instance.setModel).toHaveBeenCalledWith({
- original: model.getOriginalModel(),
- modified: model.getModel(),
- });
- });
-
- it('attaches the model to the dirty diff controller', () => {
- spyOn(instance.dirtyDiffController, 'attachModel');
-
- instance.attachModel(model);
-
- expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith(model);
- });
-
- it('re-decorates with the dirty diff controller', () => {
- spyOn(instance.dirtyDiffController, 'reDecorate');
-
- instance.attachModel(model);
-
- expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith(model);
- });
- });
-
- describe('attachMergeRequestModel', () => {
- let model;
-
- beforeEach(() => {
- instance.createDiffInstance(document.createElement('div'));
-
- const f = file();
- f.mrChanges = { diff: 'ABC' };
- f.baseRaw = 'testing';
-
- model = instance.createModel(f);
- });
-
- it('sets original & modified', () => {
- spyOn(instance.instance, 'setModel');
-
- instance.attachMergeRequestModel(model);
-
- expect(instance.instance.setModel).toHaveBeenCalledWith({
- original: model.getBaseModel(),
- modified: model.getModel(),
- });
- });
- });
-
- describe('clearEditor', () => {
- it('resets the editor model', () => {
- instance.createInstance(document.createElement('div'));
-
- spyOn(instance.instance, 'setModel');
-
- instance.clearEditor();
-
- expect(instance.instance.setModel).toHaveBeenCalledWith(null);
- });
- });
-
- describe('dispose', () => {
- it('calls disposble dispose method', () => {
- spyOn(instance.disposable, 'dispose').and.callThrough();
-
- instance.dispose();
-
- expect(instance.disposable.dispose).toHaveBeenCalled();
- });
-
- it('resets instance', () => {
- instance.createInstance(document.createElement('div'));
-
- expect(instance.instance).not.toBeNull();
-
- instance.dispose();
-
- expect(instance.instance).toBeNull();
- });
-
- it('does not dispose modelManager', () => {
- spyOn(instance.modelManager, 'dispose');
-
- instance.dispose();
-
- expect(instance.modelManager.dispose).not.toHaveBeenCalled();
- });
-
- it('does not dispose decorationsController', () => {
- spyOn(instance.decorationsController, 'dispose');
-
- instance.dispose();
-
- expect(instance.decorationsController.dispose).not.toHaveBeenCalled();
- });
- });
-
- describe('updateDiffView', () => {
- describe('edit mode', () => {
- it('does not update options', () => {
- instance.createInstance(holder);
-
- spyOn(instance.instance, 'updateOptions');
-
- instance.updateDiffView();
-
- expect(instance.instance.updateOptions).not.toHaveBeenCalled();
- });
- });
-
- describe('diff mode', () => {
- beforeEach(() => {
- instance.createDiffInstance(holder);
-
- spyOn(instance.instance, 'updateOptions').and.callThrough();
- });
-
- it('sets renderSideBySide to false if el is less than 700 pixels', () => {
- spyOnProperty(instance.instance.getDomNode(), 'offsetWidth').and.returnValue(600);
-
- expect(instance.instance.updateOptions).not.toHaveBeenCalledWith({
- renderSideBySide: false,
- });
- });
-
- it('sets renderSideBySide to false if el is more than 700 pixels', () => {
- spyOnProperty(instance.instance.getDomNode(), 'offsetWidth').and.returnValue(800);
-
- expect(instance.instance.updateOptions).not.toHaveBeenCalledWith({
- renderSideBySide: true,
- });
- });
- });
- });
-
- describe('isDiffEditorType', () => {
- it('returns true when diff editor', () => {
- instance.createDiffInstance(holder);
-
- expect(instance.isDiffEditorType).toBe(true);
- });
-
- it('returns false when not diff editor', () => {
- instance.createInstance(holder);
-
- expect(instance.isDiffEditorType).toBe(false);
- });
- });
-
- it('sets quickSuggestions to false when language is markdown', () => {
- instance.createInstance(holder);
-
- spyOn(instance.instance, 'updateOptions').and.callThrough();
-
- const model = instance.createModel({
- ...file(),
- key: 'index.md',
- path: 'index.md',
- });
-
- instance.attachModel(model);
-
- expect(instance.instance.updateOptions).toHaveBeenCalledWith({
- readOnly: false,
- quickSuggestions: false,
- });
- });
-});
diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js
index fabe44ce333..2201a3b4b57 100644
--- a/spec/javascripts/ide/stores/actions/tree_spec.js
+++ b/spec/javascripts/ide/stores/actions/tree_spec.js
@@ -30,6 +30,7 @@ describe('Multi-file store tree actions', () => {
store.state.currentBranchId = 'master';
store.state.projects.abcproject = {
web_url: '',
+ path_with_namespace: 'foo/abcproject',
};
});
@@ -57,7 +58,7 @@ describe('Multi-file store tree actions', () => {
store
.dispatch('getFiles', basicCallParameters)
.then(() => {
- expect(service.getFiles).toHaveBeenCalledWith('', '12345678');
+ expect(service.getFiles).toHaveBeenCalledWith('foo/abcproject', '12345678');
done();
})
diff --git a/spec/javascripts/image_diff/helpers/badge_helper_spec.js b/spec/javascripts/image_diff/helpers/badge_helper_spec.js
deleted file mode 100644
index b3001d45e3c..00000000000
--- a/spec/javascripts/image_diff/helpers/badge_helper_spec.js
+++ /dev/null
@@ -1,130 +0,0 @@
-import * as badgeHelper from '~/image_diff/helpers/badge_helper';
-import * as mockData from '../mock_data';
-
-describe('badge helper', () => {
- const { coordinate, noteId, badgeText, badgeNumber } = mockData;
- let containerEl;
- let buttonEl;
-
- beforeEach(() => {
- containerEl = document.createElement('div');
- });
-
- describe('createImageBadge', () => {
- beforeEach(() => {
- buttonEl = badgeHelper.createImageBadge(noteId, coordinate);
- });
-
- it('should create button', () => {
- expect(buttonEl.tagName).toEqual('BUTTON');
- expect(buttonEl.getAttribute('type')).toEqual('button');
- });
-
- it('should set disabled attribute', () => {
- expect(buttonEl.hasAttribute('disabled')).toEqual(true);
- });
-
- it('should set noteId', () => {
- expect(buttonEl.dataset.noteId).toEqual(noteId);
- });
-
- it('should set coordinate', () => {
- expect(buttonEl.style.left).toEqual(`${coordinate.x}px`);
- expect(buttonEl.style.top).toEqual(`${coordinate.y}px`);
- });
-
- describe('classNames', () => {
- it('should set .js-image-badge by default', () => {
- expect(buttonEl.className).toEqual('js-image-badge');
- });
-
- it('should add additional class names if parameter is passed', () => {
- const classNames = ['first-class', 'second-class'];
- buttonEl = badgeHelper.createImageBadge(noteId, coordinate, classNames);
-
- expect(buttonEl.className).toEqual(classNames.concat('js-image-badge').join(' '));
- });
- });
- });
-
- describe('addImageBadge', () => {
- beforeEach(() => {
- badgeHelper.addImageBadge(containerEl, {
- coordinate,
- badgeText,
- noteId,
- });
- buttonEl = containerEl.querySelector('button');
- });
-
- it('should appends button to container', () => {
- expect(buttonEl).toBeDefined();
- });
-
- it('should add badge classes', () => {
- expect(buttonEl.className).toContain('badge badge-pill');
- });
-
- it('should set the badge text', () => {
- expect(buttonEl.innerText).toEqual(badgeText);
- });
-
- it('should set the button coordinates', () => {
- expect(buttonEl.style.left).toEqual(`${coordinate.x}px`);
- expect(buttonEl.style.top).toEqual(`${coordinate.y}px`);
- });
-
- it('should set the button noteId', () => {
- expect(buttonEl.dataset.noteId).toEqual(noteId);
- });
- });
-
- describe('addImageCommentBadge', () => {
- beforeEach(() => {
- badgeHelper.addImageCommentBadge(containerEl, {
- coordinate,
- noteId,
- });
- buttonEl = containerEl.querySelector('button');
- });
-
- it('should append icon button to container', () => {
- expect(buttonEl).toBeDefined();
- });
-
- it('should create icon comment button', () => {
- const iconEl = buttonEl.querySelector('svg');
-
- expect(iconEl).toBeDefined();
- });
- });
-
- describe('addAvatarBadge', () => {
- let avatarBadgeEl;
-
- beforeEach(() => {
- containerEl.innerHTML = `
- <div id="${noteId}">
- <div class="badge hidden">
- </div>
- </div>
- `;
-
- badgeHelper.addAvatarBadge(containerEl, {
- detail: {
- noteId,
- badgeNumber,
- },
- });
- avatarBadgeEl = containerEl.querySelector(`#${noteId} .badge`);
- });
-
- it('should update badge number', () => {
- expect(avatarBadgeEl.innerText).toEqual(badgeNumber.toString());
- });
-
- it('should remove hidden class', () => {
- expect(avatarBadgeEl.classList.contains('hidden')).toEqual(false);
- });
- });
-});
diff --git a/spec/javascripts/image_diff/helpers/comment_indicator_helper_spec.js b/spec/javascripts/image_diff/helpers/comment_indicator_helper_spec.js
deleted file mode 100644
index 8e3e7f1222e..00000000000
--- a/spec/javascripts/image_diff/helpers/comment_indicator_helper_spec.js
+++ /dev/null
@@ -1,144 +0,0 @@
-import * as commentIndicatorHelper from '~/image_diff/helpers/comment_indicator_helper';
-import * as mockData from '../mock_data';
-
-describe('commentIndicatorHelper', () => {
- const { coordinate } = mockData;
- let containerEl;
-
- beforeEach(() => {
- containerEl = document.createElement('div');
- });
-
- describe('addCommentIndicator', () => {
- let buttonEl;
-
- beforeEach(() => {
- commentIndicatorHelper.addCommentIndicator(containerEl, coordinate);
- buttonEl = containerEl.querySelector('button');
- });
-
- it('should append button to container', () => {
- expect(buttonEl).toBeDefined();
- });
-
- describe('button', () => {
- it('should set coordinate', () => {
- expect(buttonEl.style.left).toEqual(`${coordinate.x}px`);
- expect(buttonEl.style.top).toEqual(`${coordinate.y}px`);
- });
-
- it('should contain image-comment-dark svg', () => {
- const svgEl = buttonEl.querySelector('svg');
-
- expect(svgEl).toBeDefined();
-
- const svgLink = svgEl.querySelector('use').getAttribute('xlink:href');
-
- expect(svgLink.indexOf('image-comment-dark')).not.toBe(-1);
- });
- });
- });
-
- describe('removeCommentIndicator', () => {
- it('should return removed false if there is no comment-indicator', () => {
- const result = commentIndicatorHelper.removeCommentIndicator(containerEl);
-
- expect(result.removed).toEqual(false);
- });
-
- describe('has comment indicator', () => {
- let result;
-
- beforeEach(() => {
- containerEl.innerHTML = `
- <div class="comment-indicator" style="left:${coordinate.x}px; top: ${coordinate.y}px;">
- <img src="${gl.TEST_HOST}/image.png">
- </div>
- `;
- result = commentIndicatorHelper.removeCommentIndicator(containerEl);
- });
-
- it('should remove comment indicator', () => {
- expect(containerEl.querySelector('.comment-indicator')).toBeNull();
- });
-
- it('should return removed true', () => {
- expect(result.removed).toEqual(true);
- });
-
- it('should return indicator meta', () => {
- expect(result.x).toEqual(coordinate.x);
- expect(result.y).toEqual(coordinate.y);
- expect(result.image).toBeDefined();
- expect(result.image.width).toBeDefined();
- expect(result.image.height).toBeDefined();
- });
- });
- });
-
- describe('showCommentIndicator', () => {
- describe('commentIndicator exists', () => {
- beforeEach(() => {
- containerEl.innerHTML = `
- <button class="comment-indicator"></button>
- `;
- commentIndicatorHelper.showCommentIndicator(containerEl, coordinate);
- });
-
- it('should set commentIndicator coordinates', () => {
- const commentIndicatorEl = containerEl.querySelector('.comment-indicator');
-
- expect(commentIndicatorEl.style.left).toEqual(`${coordinate.x}px`);
- expect(commentIndicatorEl.style.top).toEqual(`${coordinate.y}px`);
- });
- });
-
- describe('commentIndicator does not exist', () => {
- beforeEach(() => {
- commentIndicatorHelper.showCommentIndicator(containerEl, coordinate);
- });
-
- it('should addCommentIndicator', () => {
- const buttonEl = containerEl.querySelector('.comment-indicator');
-
- expect(buttonEl).toBeDefined();
- expect(buttonEl.style.left).toEqual(`${coordinate.x}px`);
- expect(buttonEl.style.top).toEqual(`${coordinate.y}px`);
- });
- });
- });
-
- describe('commentIndicatorOnClick', () => {
- let event;
- let textAreaEl;
-
- beforeEach(() => {
- containerEl.innerHTML = `
- <div class="diff-viewer">
- <button></button>
- <div class="note-container">
- <textarea class="note-textarea"></textarea>
- </div>
- </div>
- `;
- textAreaEl = containerEl.querySelector('textarea');
-
- event = {
- stopPropagation: () => {},
- currentTarget: containerEl.querySelector('button'),
- };
-
- spyOn(event, 'stopPropagation');
- spyOn(textAreaEl, 'focus');
- commentIndicatorHelper.commentIndicatorOnClick(event);
- });
-
- it('should stopPropagation', () => {
- expect(event.stopPropagation).toHaveBeenCalled();
- });
-
- it('should focus textAreaEl', () => {
- expect(textAreaEl.focus).toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/javascripts/image_diff/helpers/dom_helper_spec.js b/spec/javascripts/image_diff/helpers/dom_helper_spec.js
deleted file mode 100644
index ffe712af2dd..00000000000
--- a/spec/javascripts/image_diff/helpers/dom_helper_spec.js
+++ /dev/null
@@ -1,120 +0,0 @@
-import * as domHelper from '~/image_diff/helpers/dom_helper';
-import * as mockData from '../mock_data';
-
-describe('domHelper', () => {
- const { imageMeta, badgeNumber } = mockData;
-
- describe('setPositionDataAttribute', () => {
- let containerEl;
- let attributeAfterCall;
- const position = {
- myProperty: 'myProperty',
- };
-
- beforeEach(() => {
- containerEl = document.createElement('div');
- containerEl.dataset.position = JSON.stringify(position);
- domHelper.setPositionDataAttribute(containerEl, imageMeta);
- attributeAfterCall = JSON.parse(containerEl.dataset.position);
- });
-
- it('should set x, y, width, height', () => {
- expect(attributeAfterCall.x).toEqual(imageMeta.x);
- expect(attributeAfterCall.y).toEqual(imageMeta.y);
- expect(attributeAfterCall.width).toEqual(imageMeta.width);
- expect(attributeAfterCall.height).toEqual(imageMeta.height);
- });
-
- it('should not override other properties', () => {
- expect(attributeAfterCall.myProperty).toEqual('myProperty');
- });
- });
-
- describe('updateDiscussionAvatarBadgeNumber', () => {
- let discussionEl;
-
- beforeEach(() => {
- discussionEl = document.createElement('div');
- discussionEl.innerHTML = `
- <a href="#" class="image-diff-avatar-link">
- <div class="badge"></div>
- </a>
- `;
- domHelper.updateDiscussionAvatarBadgeNumber(discussionEl, badgeNumber);
- });
-
- it('should update avatar badge number', () => {
- expect(discussionEl.querySelector('.badge').innerText).toEqual(badgeNumber.toString());
- });
- });
-
- describe('updateDiscussionBadgeNumber', () => {
- let discussionEl;
-
- beforeEach(() => {
- discussionEl = document.createElement('div');
- discussionEl.innerHTML = `
- <div class="badge"></div>
- `;
- domHelper.updateDiscussionBadgeNumber(discussionEl, badgeNumber);
- });
-
- it('should update discussion badge number', () => {
- expect(discussionEl.querySelector('.badge').innerText).toEqual(badgeNumber.toString());
- });
- });
-
- describe('toggleCollapsed', () => {
- let element;
- let discussionNotesEl;
-
- beforeEach(() => {
- element = document.createElement('div');
- element.innerHTML = `
- <div class="discussion-notes">
- <button></button>
- <form class="discussion-form"></form>
- </div>
- `;
- discussionNotesEl = element.querySelector('.discussion-notes');
- });
-
- describe('not collapsed', () => {
- beforeEach(() => {
- domHelper.toggleCollapsed({
- currentTarget: element.querySelector('button'),
- });
- });
-
- it('should add collapsed class', () => {
- expect(discussionNotesEl.classList.contains('collapsed')).toEqual(true);
- });
-
- it('should force formEl to display none', () => {
- const formEl = element.querySelector('.discussion-form');
-
- expect(formEl.style.display).toEqual('none');
- });
- });
-
- describe('collapsed', () => {
- beforeEach(() => {
- discussionNotesEl.classList.add('collapsed');
-
- domHelper.toggleCollapsed({
- currentTarget: element.querySelector('button'),
- });
- });
-
- it('should remove collapsed class', () => {
- expect(discussionNotesEl.classList.contains('collapsed')).toEqual(false);
- });
-
- it('should force formEl to display block', () => {
- const formEl = element.querySelector('.discussion-form');
-
- expect(formEl.style.display).toEqual('block');
- });
- });
- });
-});
diff --git a/spec/javascripts/image_diff/image_badge_spec.js b/spec/javascripts/image_diff/image_badge_spec.js
deleted file mode 100644
index 2b23dce5d30..00000000000
--- a/spec/javascripts/image_diff/image_badge_spec.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import ImageBadge from '~/image_diff/image_badge';
-import imageDiffHelper from '~/image_diff/helpers/index';
-import * as mockData from './mock_data';
-
-describe('ImageBadge', () => {
- const { noteId, discussionId, imageMeta } = mockData;
- const options = {
- noteId,
- discussionId,
- };
-
- it('should save actual property', () => {
- const imageBadge = new ImageBadge(
- Object.assign({}, options, {
- actual: imageMeta,
- }),
- );
-
- const { actual } = imageBadge;
-
- expect(actual.x).toEqual(imageMeta.x);
- expect(actual.y).toEqual(imageMeta.y);
- expect(actual.width).toEqual(imageMeta.width);
- expect(actual.height).toEqual(imageMeta.height);
- });
-
- it('should save browser property', () => {
- const imageBadge = new ImageBadge(
- Object.assign({}, options, {
- browser: imageMeta,
- }),
- );
-
- const { browser } = imageBadge;
-
- expect(browser.x).toEqual(imageMeta.x);
- expect(browser.y).toEqual(imageMeta.y);
- expect(browser.width).toEqual(imageMeta.width);
- expect(browser.height).toEqual(imageMeta.height);
- });
-
- it('should save noteId', () => {
- const imageBadge = new ImageBadge(options);
-
- expect(imageBadge.noteId).toEqual(noteId);
- });
-
- it('should save discussionId', () => {
- const imageBadge = new ImageBadge(options);
-
- expect(imageBadge.discussionId).toEqual(discussionId);
- });
-
- describe('default values', () => {
- let imageBadge;
-
- beforeEach(() => {
- imageBadge = new ImageBadge(options);
- });
-
- it('should return defaultimageMeta if actual property is not provided', () => {
- const { actual } = imageBadge;
-
- expect(actual.x).toEqual(0);
- expect(actual.y).toEqual(0);
- expect(actual.width).toEqual(0);
- expect(actual.height).toEqual(0);
- });
-
- it('should return defaultimageMeta if browser property is not provided', () => {
- const { browser } = imageBadge;
-
- expect(browser.x).toEqual(0);
- expect(browser.y).toEqual(0);
- expect(browser.width).toEqual(0);
- expect(browser.height).toEqual(0);
- });
- });
-
- describe('imageEl property is provided and not browser property', () => {
- beforeEach(() => {
- spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').and.returnValue(true);
- });
-
- it('should generate browser property', () => {
- const imageBadge = new ImageBadge(
- Object.assign({}, options, {
- imageEl: document.createElement('img'),
- }),
- );
-
- expect(imageDiffHelper.resizeCoordinatesToImageElement).toHaveBeenCalled();
- expect(imageBadge.browser).toEqual(true);
- });
- });
-});
diff --git a/spec/javascripts/image_diff/image_diff_spec.js b/spec/javascripts/image_diff/image_diff_spec.js
deleted file mode 100644
index 21e7b8e2e9b..00000000000
--- a/spec/javascripts/image_diff/image_diff_spec.js
+++ /dev/null
@@ -1,361 +0,0 @@
-import ImageDiff from '~/image_diff/image_diff';
-import * as imageUtility from '~/lib/utils/image_utility';
-import imageDiffHelper from '~/image_diff/helpers/index';
-import * as mockData from './mock_data';
-
-describe('ImageDiff', () => {
- let element;
- let imageDiff;
-
- beforeEach(() => {
- setFixtures(`
- <div id="element">
- <div class="diff-file">
- <div class="js-image-frame">
- <img src="${gl.TEST_HOST}/image.png">
- <div class="comment-indicator"></div>
- <div id="badge-1" class="badge">1</div>
- <div id="badge-2" class="badge">2</div>
- <div id="badge-3" class="badge">3</div>
- </div>
- <div class="note-container">
- <div class="discussion-notes">
- <div class="js-diff-notes-toggle"></div>
- <div class="notes"></div>
- </div>
- <div class="discussion-notes">
- <div class="js-diff-notes-toggle"></div>
- <div class="notes"></div>
- </div>
- </div>
- </div>
- </div>
- `);
- element = document.getElementById('element');
- });
-
- describe('constructor', () => {
- beforeEach(() => {
- imageDiff = new ImageDiff(element, {
- canCreateNote: true,
- renderCommentBadge: true,
- });
- });
-
- it('should set el', () => {
- expect(imageDiff.el).toEqual(element);
- });
-
- it('should set canCreateNote', () => {
- expect(imageDiff.canCreateNote).toEqual(true);
- });
-
- it('should set renderCommentBadge', () => {
- expect(imageDiff.renderCommentBadge).toEqual(true);
- });
-
- it('should set $noteContainer', () => {
- expect(imageDiff.$noteContainer[0]).toEqual(element.querySelector('.note-container'));
- });
-
- describe('default', () => {
- beforeEach(() => {
- imageDiff = new ImageDiff(element);
- });
-
- it('should set canCreateNote as false', () => {
- expect(imageDiff.canCreateNote).toEqual(false);
- });
-
- it('should set renderCommentBadge as false', () => {
- expect(imageDiff.renderCommentBadge).toEqual(false);
- });
- });
- });
-
- describe('init', () => {
- beforeEach(() => {
- spyOn(ImageDiff.prototype, 'bindEvents').and.callFake(() => {});
- imageDiff = new ImageDiff(element);
- imageDiff.init();
- });
-
- it('should set imageFrameEl', () => {
- expect(imageDiff.imageFrameEl).toEqual(element.querySelector('.diff-file .js-image-frame'));
- });
-
- it('should set imageEl', () => {
- expect(imageDiff.imageEl).toEqual(element.querySelector('.diff-file .js-image-frame img'));
- });
-
- it('should call bindEvents', () => {
- expect(imageDiff.bindEvents).toHaveBeenCalled();
- });
- });
-
- describe('bindEvents', () => {
- let imageEl;
-
- beforeEach(() => {
- spyOn(imageDiffHelper, 'toggleCollapsed').and.callFake(() => {});
- spyOn(imageDiffHelper, 'commentIndicatorOnClick').and.callFake(() => {});
- spyOn(imageDiffHelper, 'removeCommentIndicator').and.callFake(() => {});
- spyOn(ImageDiff.prototype, 'imageClicked').and.callFake(() => {});
- spyOn(ImageDiff.prototype, 'addBadge').and.callFake(() => {});
- spyOn(ImageDiff.prototype, 'removeBadge').and.callFake(() => {});
- spyOn(ImageDiff.prototype, 'renderBadges').and.callFake(() => {});
- imageEl = element.querySelector('.diff-file .js-image-frame img');
- });
-
- describe('default', () => {
- beforeEach(() => {
- spyOn(imageUtility, 'isImageLoaded').and.returnValue(false);
- imageDiff = new ImageDiff(element);
- imageDiff.imageEl = imageEl;
- imageDiff.bindEvents();
- });
-
- it('should register click event delegation to js-diff-notes-toggle', () => {
- element.querySelector('.js-diff-notes-toggle').click();
-
- expect(imageDiffHelper.toggleCollapsed).toHaveBeenCalled();
- });
-
- it('should register click event delegation to comment-indicator', () => {
- element.querySelector('.comment-indicator').click();
-
- expect(imageDiffHelper.commentIndicatorOnClick).toHaveBeenCalled();
- });
- });
-
- describe('image not loaded', () => {
- beforeEach(() => {
- spyOn(imageUtility, 'isImageLoaded').and.returnValue(false);
- imageDiff = new ImageDiff(element);
- imageDiff.imageEl = imageEl;
- imageDiff.bindEvents();
- });
-
- it('should registers load eventListener', () => {
- const loadEvent = new Event('load');
- imageEl.dispatchEvent(loadEvent);
-
- expect(imageDiff.renderBadges).toHaveBeenCalled();
- });
- });
-
- describe('canCreateNote', () => {
- beforeEach(() => {
- spyOn(imageUtility, 'isImageLoaded').and.returnValue(false);
- imageDiff = new ImageDiff(element, {
- canCreateNote: true,
- });
- imageDiff.imageEl = imageEl;
- imageDiff.bindEvents();
- });
-
- it('should register click.imageDiff event', () => {
- const event = new CustomEvent('click.imageDiff');
- element.dispatchEvent(event);
-
- expect(imageDiff.imageClicked).toHaveBeenCalled();
- });
-
- it('should register blur.imageDiff event', () => {
- const event = new CustomEvent('blur.imageDiff');
- element.dispatchEvent(event);
-
- expect(imageDiffHelper.removeCommentIndicator).toHaveBeenCalled();
- });
-
- it('should register addBadge.imageDiff event', () => {
- const event = new CustomEvent('addBadge.imageDiff');
- element.dispatchEvent(event);
-
- expect(imageDiff.addBadge).toHaveBeenCalled();
- });
-
- it('should register removeBadge.imageDiff event', () => {
- const event = new CustomEvent('removeBadge.imageDiff');
- element.dispatchEvent(event);
-
- expect(imageDiff.removeBadge).toHaveBeenCalled();
- });
- });
-
- describe('canCreateNote is false', () => {
- beforeEach(() => {
- spyOn(imageUtility, 'isImageLoaded').and.returnValue(false);
- imageDiff = new ImageDiff(element);
- imageDiff.imageEl = imageEl;
- imageDiff.bindEvents();
- });
-
- it('should not register click.imageDiff event', () => {
- const event = new CustomEvent('click.imageDiff');
- element.dispatchEvent(event);
-
- expect(imageDiff.imageClicked).not.toHaveBeenCalled();
- });
- });
- });
-
- describe('imageClicked', () => {
- beforeEach(() => {
- spyOn(imageDiffHelper, 'getTargetSelection').and.returnValue({
- actual: {},
- browser: {},
- });
- spyOn(imageDiffHelper, 'setPositionDataAttribute').and.callFake(() => {});
- spyOn(imageDiffHelper, 'showCommentIndicator').and.callFake(() => {});
- imageDiff = new ImageDiff(element);
- imageDiff.imageClicked({
- detail: {
- currentTarget: {},
- },
- });
- });
-
- it('should call getTargetSelection', () => {
- expect(imageDiffHelper.getTargetSelection).toHaveBeenCalled();
- });
-
- it('should call setPositionDataAttribute', () => {
- expect(imageDiffHelper.setPositionDataAttribute).toHaveBeenCalled();
- });
-
- it('should call showCommentIndicator', () => {
- expect(imageDiffHelper.showCommentIndicator).toHaveBeenCalled();
- });
- });
-
- describe('renderBadges', () => {
- beforeEach(() => {
- spyOn(ImageDiff.prototype, 'renderBadge').and.callFake(() => {});
- imageDiff = new ImageDiff(element);
- imageDiff.renderBadges();
- });
-
- it('should call renderBadge for each discussionEl', () => {
- const discussionEls = element.querySelectorAll('.note-container .discussion-notes .notes');
-
- expect(imageDiff.renderBadge.calls.count()).toEqual(discussionEls.length);
- });
- });
-
- describe('renderBadge', () => {
- let discussionEls;
-
- beforeEach(() => {
- spyOn(imageDiffHelper, 'addImageBadge').and.callFake(() => {});
- spyOn(imageDiffHelper, 'addImageCommentBadge').and.callFake(() => {});
- spyOn(imageDiffHelper, 'generateBadgeFromDiscussionDOM').and.returnValue({
- browser: {},
- noteId: 'noteId',
- });
- discussionEls = element.querySelectorAll('.note-container .discussion-notes .notes');
- imageDiff = new ImageDiff(element);
- imageDiff.renderBadge(discussionEls[0], 0);
- });
-
- it('should populate imageBadges', () => {
- expect(imageDiff.imageBadges.length).toEqual(1);
- });
-
- describe('renderCommentBadge', () => {
- beforeEach(() => {
- imageDiff.renderCommentBadge = true;
- imageDiff.renderBadge(discussionEls[0], 0);
- });
-
- it('should call addImageCommentBadge', () => {
- expect(imageDiffHelper.addImageCommentBadge).toHaveBeenCalled();
- });
- });
-
- describe('renderCommentBadge is false', () => {
- it('should call addImageBadge', () => {
- expect(imageDiffHelper.addImageBadge).toHaveBeenCalled();
- });
- });
- });
-
- describe('addBadge', () => {
- beforeEach(() => {
- spyOn(imageDiffHelper, 'addImageBadge').and.callFake(() => {});
- spyOn(imageDiffHelper, 'addAvatarBadge').and.callFake(() => {});
- spyOn(imageDiffHelper, 'updateDiscussionBadgeNumber').and.callFake(() => {});
- imageDiff = new ImageDiff(element);
- imageDiff.imageFrameEl = element.querySelector('.diff-file .js-image-frame');
- imageDiff.addBadge({
- detail: {
- x: 0,
- y: 1,
- width: 25,
- height: 50,
- noteId: 'noteId',
- discussionId: 'discussionId',
- },
- });
- });
-
- it('should add imageBadge to imageBadges', () => {
- expect(imageDiff.imageBadges.length).toEqual(1);
- });
-
- it('should call addImageBadge', () => {
- expect(imageDiffHelper.addImageBadge).toHaveBeenCalled();
- });
-
- it('should call addAvatarBadge', () => {
- expect(imageDiffHelper.addAvatarBadge).toHaveBeenCalled();
- });
-
- it('should call updateDiscussionBadgeNumber', () => {
- expect(imageDiffHelper.updateDiscussionBadgeNumber).toHaveBeenCalled();
- });
- });
-
- describe('removeBadge', () => {
- beforeEach(() => {
- const { imageMeta } = mockData;
-
- spyOn(imageDiffHelper, 'updateDiscussionBadgeNumber').and.callFake(() => {});
- spyOn(imageDiffHelper, 'updateDiscussionAvatarBadgeNumber').and.callFake(() => {});
- imageDiff = new ImageDiff(element);
- imageDiff.imageBadges = [imageMeta, imageMeta, imageMeta];
- imageDiff.imageFrameEl = element.querySelector('.diff-file .js-image-frame');
- imageDiff.removeBadge({
- detail: {
- badgeNumber: 2,
- },
- });
- });
-
- describe('cascade badge count', () => {
- it('should update next imageBadgeEl value', () => {
- const imageBadgeEls = imageDiff.imageFrameEl.querySelectorAll('.badge');
-
- expect(imageBadgeEls[0].innerText).toEqual('1');
- expect(imageBadgeEls[1].innerText).toEqual('2');
- expect(imageBadgeEls.length).toEqual(2);
- });
-
- it('should call updateDiscussionBadgeNumber', () => {
- expect(imageDiffHelper.updateDiscussionBadgeNumber).toHaveBeenCalled();
- });
-
- it('should call updateDiscussionAvatarBadgeNumber', () => {
- expect(imageDiffHelper.updateDiscussionAvatarBadgeNumber).toHaveBeenCalled();
- });
- });
-
- it('should remove badge from imageBadges', () => {
- expect(imageDiff.imageBadges.length).toEqual(2);
- });
-
- it('should remove imageBadgeEl', () => {
- expect(imageDiff.imageFrameEl.querySelector('#badge-2')).toBeNull();
- });
- });
-});
diff --git a/spec/javascripts/image_diff/replaced_image_diff_spec.js b/spec/javascripts/image_diff/replaced_image_diff_spec.js
deleted file mode 100644
index 62e7c8b6c6a..00000000000
--- a/spec/javascripts/image_diff/replaced_image_diff_spec.js
+++ /dev/null
@@ -1,355 +0,0 @@
-import ReplacedImageDiff from '~/image_diff/replaced_image_diff';
-import ImageDiff from '~/image_diff/image_diff';
-import { viewTypes } from '~/image_diff/view_types';
-import imageDiffHelper from '~/image_diff/helpers/index';
-
-describe('ReplacedImageDiff', () => {
- let element;
- let replacedImageDiff;
-
- beforeEach(() => {
- setFixtures(`
- <div id="element">
- <div class="two-up">
- <div class="js-image-frame">
- <img src="${gl.TEST_HOST}/image.png">
- </div>
- </div>
- <div class="swipe">
- <div class="js-image-frame">
- <img src="${gl.TEST_HOST}/image.png">
- </div>
- </div>
- <div class="onion-skin">
- <div class="js-image-frame">
- <img src="${gl.TEST_HOST}/image.png">
- </div>
- </div>
- <div class="view-modes-menu">
- <div class="two-up">2-up</div>
- <div class="swipe">Swipe</div>
- <div class="onion-skin">Onion skin</div>
- </div>
- </div>
- `);
- element = document.getElementById('element');
- });
-
- function setupImageFrameEls() {
- replacedImageDiff.imageFrameEls = [];
- replacedImageDiff.imageFrameEls[viewTypes.TWO_UP] = element.querySelector(
- '.two-up .js-image-frame',
- );
- replacedImageDiff.imageFrameEls[viewTypes.SWIPE] = element.querySelector(
- '.swipe .js-image-frame',
- );
- replacedImageDiff.imageFrameEls[viewTypes.ONION_SKIN] = element.querySelector(
- '.onion-skin .js-image-frame',
- );
- }
-
- function setupViewModesEls() {
- replacedImageDiff.viewModesEls = [];
- replacedImageDiff.viewModesEls[viewTypes.TWO_UP] = element.querySelector(
- '.view-modes-menu .two-up',
- );
- replacedImageDiff.viewModesEls[viewTypes.SWIPE] = element.querySelector(
- '.view-modes-menu .swipe',
- );
- replacedImageDiff.viewModesEls[viewTypes.ONION_SKIN] = element.querySelector(
- '.view-modes-menu .onion-skin',
- );
- }
-
- function setupImageEls() {
- replacedImageDiff.imageEls = [];
- replacedImageDiff.imageEls[viewTypes.TWO_UP] = element.querySelector('.two-up img');
- replacedImageDiff.imageEls[viewTypes.SWIPE] = element.querySelector('.swipe img');
- replacedImageDiff.imageEls[viewTypes.ONION_SKIN] = element.querySelector('.onion-skin img');
- }
-
- it('should extend ImageDiff', () => {
- replacedImageDiff = new ReplacedImageDiff(element);
-
- expect(replacedImageDiff instanceof ImageDiff).toEqual(true);
- });
-
- describe('init', () => {
- beforeEach(() => {
- spyOn(ReplacedImageDiff.prototype, 'bindEvents').and.callFake(() => {});
- spyOn(ReplacedImageDiff.prototype, 'generateImageEls').and.callFake(() => {});
-
- replacedImageDiff = new ReplacedImageDiff(element);
- replacedImageDiff.init();
- });
-
- it('should set imageFrameEls', () => {
- const { imageFrameEls } = replacedImageDiff;
-
- expect(imageFrameEls).toBeDefined();
- expect(imageFrameEls[viewTypes.TWO_UP]).toEqual(
- element.querySelector('.two-up .js-image-frame'),
- );
-
- expect(imageFrameEls[viewTypes.SWIPE]).toEqual(
- element.querySelector('.swipe .js-image-frame'),
- );
-
- expect(imageFrameEls[viewTypes.ONION_SKIN]).toEqual(
- element.querySelector('.onion-skin .js-image-frame'),
- );
- });
-
- it('should set viewModesEls', () => {
- const { viewModesEls } = replacedImageDiff;
-
- expect(viewModesEls).toBeDefined();
- expect(viewModesEls[viewTypes.TWO_UP]).toEqual(
- element.querySelector('.view-modes-menu .two-up'),
- );
-
- expect(viewModesEls[viewTypes.SWIPE]).toEqual(
- element.querySelector('.view-modes-menu .swipe'),
- );
-
- expect(viewModesEls[viewTypes.ONION_SKIN]).toEqual(
- element.querySelector('.view-modes-menu .onion-skin'),
- );
- });
-
- it('should generateImageEls', () => {
- expect(ReplacedImageDiff.prototype.generateImageEls).toHaveBeenCalled();
- });
-
- it('should bindEvents', () => {
- expect(ReplacedImageDiff.prototype.bindEvents).toHaveBeenCalled();
- });
-
- describe('currentView', () => {
- it('should set currentView', () => {
- replacedImageDiff.init(viewTypes.ONION_SKIN);
-
- expect(replacedImageDiff.currentView).toEqual(viewTypes.ONION_SKIN);
- });
-
- it('should default to viewTypes.TWO_UP', () => {
- expect(replacedImageDiff.currentView).toEqual(viewTypes.TWO_UP);
- });
- });
- });
-
- describe('generateImageEls', () => {
- beforeEach(() => {
- spyOn(ReplacedImageDiff.prototype, 'bindEvents').and.callFake(() => {});
-
- replacedImageDiff = new ReplacedImageDiff(element, {
- canCreateNote: false,
- renderCommentBadge: false,
- });
-
- setupImageFrameEls();
- });
-
- it('should set imageEls', () => {
- replacedImageDiff.generateImageEls();
- const { imageEls } = replacedImageDiff;
-
- expect(imageEls).toBeDefined();
- expect(imageEls[viewTypes.TWO_UP]).toEqual(element.querySelector('.two-up img'));
- expect(imageEls[viewTypes.SWIPE]).toEqual(element.querySelector('.swipe img'));
- expect(imageEls[viewTypes.ONION_SKIN]).toEqual(element.querySelector('.onion-skin img'));
- });
- });
-
- describe('bindEvents', () => {
- beforeEach(() => {
- spyOn(ImageDiff.prototype, 'bindEvents').and.callFake(() => {});
- replacedImageDiff = new ReplacedImageDiff(element);
-
- setupViewModesEls();
- });
-
- it('should call super.bindEvents', () => {
- replacedImageDiff.bindEvents();
-
- expect(ImageDiff.prototype.bindEvents).toHaveBeenCalled();
- });
-
- it('should register click eventlistener to 2-up view mode', done => {
- spyOn(ReplacedImageDiff.prototype, 'changeView').and.callFake(viewMode => {
- expect(viewMode).toEqual(viewTypes.TWO_UP);
- done();
- });
-
- replacedImageDiff.bindEvents();
- replacedImageDiff.viewModesEls[viewTypes.TWO_UP].click();
- });
-
- it('should register click eventlistener to swipe view mode', done => {
- spyOn(ReplacedImageDiff.prototype, 'changeView').and.callFake(viewMode => {
- expect(viewMode).toEqual(viewTypes.SWIPE);
- done();
- });
-
- replacedImageDiff.bindEvents();
- replacedImageDiff.viewModesEls[viewTypes.SWIPE].click();
- });
-
- it('should register click eventlistener to onion skin view mode', done => {
- spyOn(ReplacedImageDiff.prototype, 'changeView').and.callFake(viewMode => {
- expect(viewMode).toEqual(viewTypes.SWIPE);
- done();
- });
-
- replacedImageDiff.bindEvents();
- replacedImageDiff.viewModesEls[viewTypes.SWIPE].click();
- });
- });
-
- describe('getters', () => {
- describe('imageEl', () => {
- beforeEach(() => {
- replacedImageDiff = new ReplacedImageDiff(element);
- replacedImageDiff.currentView = viewTypes.TWO_UP;
- setupImageEls();
- });
-
- it('should return imageEl based on currentView', () => {
- expect(replacedImageDiff.imageEl).toEqual(element.querySelector('.two-up img'));
-
- replacedImageDiff.currentView = viewTypes.SWIPE;
-
- expect(replacedImageDiff.imageEl).toEqual(element.querySelector('.swipe img'));
- });
- });
-
- describe('imageFrameEl', () => {
- beforeEach(() => {
- replacedImageDiff = new ReplacedImageDiff(element);
- replacedImageDiff.currentView = viewTypes.TWO_UP;
- setupImageFrameEls();
- });
-
- it('should return imageFrameEl based on currentView', () => {
- expect(replacedImageDiff.imageFrameEl).toEqual(
- element.querySelector('.two-up .js-image-frame'),
- );
-
- replacedImageDiff.currentView = viewTypes.ONION_SKIN;
-
- expect(replacedImageDiff.imageFrameEl).toEqual(
- element.querySelector('.onion-skin .js-image-frame'),
- );
- });
- });
- });
-
- describe('changeView', () => {
- beforeEach(() => {
- replacedImageDiff = new ReplacedImageDiff(element);
- spyOn(imageDiffHelper, 'removeCommentIndicator').and.returnValue({
- removed: false,
- });
- setupImageFrameEls();
- });
-
- describe('invalid viewType', () => {
- beforeEach(() => {
- replacedImageDiff.changeView('some-view-name');
- });
-
- it('should not call removeCommentIndicator', () => {
- expect(imageDiffHelper.removeCommentIndicator).not.toHaveBeenCalled();
- });
- });
-
- describe('valid viewType', () => {
- beforeEach(() => {
- jasmine.clock().install();
- spyOn(ReplacedImageDiff.prototype, 'renderNewView').and.callFake(() => {});
- replacedImageDiff.changeView(viewTypes.ONION_SKIN);
- });
-
- afterEach(() => {
- jasmine.clock().uninstall();
- });
-
- it('should call removeCommentIndicator', () => {
- expect(imageDiffHelper.removeCommentIndicator).toHaveBeenCalled();
- });
-
- it('should update currentView to newView', () => {
- expect(replacedImageDiff.currentView).toEqual(viewTypes.ONION_SKIN);
- });
-
- it('should clear imageBadges', () => {
- expect(replacedImageDiff.imageBadges.length).toEqual(0);
- });
-
- it('should call renderNewView', () => {
- jasmine.clock().tick(251);
-
- expect(replacedImageDiff.renderNewView).toHaveBeenCalled();
- });
- });
- });
-
- describe('renderNewView', () => {
- beforeEach(() => {
- replacedImageDiff = new ReplacedImageDiff(element);
- });
-
- it('should call renderBadges', () => {
- spyOn(ReplacedImageDiff.prototype, 'renderBadges').and.callFake(() => {});
-
- replacedImageDiff.renderNewView({
- removed: false,
- });
-
- expect(replacedImageDiff.renderBadges).toHaveBeenCalled();
- });
-
- describe('removeIndicator', () => {
- const indicator = {
- removed: true,
- x: 0,
- y: 1,
- image: {
- width: 50,
- height: 100,
- },
- };
-
- beforeEach(() => {
- setupImageEls();
- setupImageFrameEls();
- });
-
- it('should pass showCommentIndicator normalized indicator values', done => {
- spyOn(imageDiffHelper, 'showCommentIndicator').and.callFake(() => {});
- spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').and.callFake((imageEl, meta) => {
- expect(meta.x).toEqual(indicator.x);
- expect(meta.y).toEqual(indicator.y);
- expect(meta.width).toEqual(indicator.image.width);
- expect(meta.height).toEqual(indicator.image.height);
- done();
- });
- replacedImageDiff.renderNewView(indicator);
- });
-
- it('should call showCommentIndicator', done => {
- const normalized = {
- normalized: true,
- };
- spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').and.returnValue(normalized);
- spyOn(imageDiffHelper, 'showCommentIndicator').and.callFake(
- (imageFrameEl, normalizedIndicator) => {
- expect(normalizedIndicator).toEqual(normalized);
- done();
- },
- );
- replacedImageDiff.renderNewView(indicator);
- });
- });
- });
-});
diff --git a/spec/javascripts/integrations/integration_settings_form_spec.js b/spec/javascripts/integrations/integration_settings_form_spec.js
deleted file mode 100644
index 72d04be822f..00000000000
--- a/spec/javascripts/integrations/integration_settings_form_spec.js
+++ /dev/null
@@ -1,301 +0,0 @@
-import $ from 'jquery';
-import MockAdaptor from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
-import IntegrationSettingsForm from '~/integrations/integration_settings_form';
-
-describe('IntegrationSettingsForm', () => {
- const FIXTURE = 'services/edit_service.html';
- preloadFixtures(FIXTURE);
-
- beforeEach(() => {
- loadFixtures(FIXTURE);
- });
-
- describe('contructor', () => {
- let integrationSettingsForm;
-
- beforeEach(() => {
- integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
- spyOn(integrationSettingsForm, 'init');
- });
-
- it('should initialize form element refs on class object', () => {
- // Form Reference
- 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();
- });
- });
-
- describe('toggleServiceState', () => {
- let integrationSettingsForm;
-
- beforeEach(() => {
- integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
- });
-
- it('should remove `novalidate` attribute to form when called with `true`', () => {
- integrationSettingsForm.formActive = true;
- integrationSettingsForm.toggleServiceState();
-
- expect(integrationSettingsForm.$form.attr('novalidate')).not.toBeDefined();
- });
-
- it('should set `novalidate` attribute to form when called with `false`', () => {
- integrationSettingsForm.formActive = false;
- integrationSettingsForm.toggleServiceState();
-
- expect(integrationSettingsForm.$form.attr('novalidate')).toBeDefined();
- });
- });
-
- 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;
- let mock;
-
- beforeEach(() => {
- mock = new MockAdaptor(axios);
-
- spyOn(axios, 'put').and.callThrough();
-
- integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
- // eslint-disable-next-line no-jquery/no-serialize
- formData = integrationSettingsForm.$form.serialize();
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('should make an ajax request with provided `formData`', done => {
- integrationSettingsForm
- .testSettings(formData)
- .then(() => {
- expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData);
-
- done();
- })
- .catch(done.fail);
- });
-
- it('should show error Flash with `Save anyway` action if ajax request responds with error in test', done => {
- const errorMessage = 'Test failed.';
- mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
- error: true,
- message: errorMessage,
- service_response: 'some error',
- test_failed: true,
- });
-
- 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');
-
- done();
- })
- .catch(done.fail);
- });
-
- it('should not show error Flash with `Save anyway` action if ajax request responds with error in validation', done => {
- const errorMessage = 'Validations failed.';
- mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
- error: true,
- message: errorMessage,
- service_response: 'some error',
- test_failed: false,
- });
-
- 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('');
-
- done();
- })
- .catch(done.fail);
- });
-
- it('should submit form if ajax request responds without any error in test', done => {
- spyOn(integrationSettingsForm.$form, 'submit');
-
- mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
- error: false,
- });
-
- integrationSettingsForm
- .testSettings(formData)
- .then(() => {
- expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
-
- done();
- })
- .catch(done.fail);
- });
-
- it('should submit form when clicked on `Save anyway` action of error Flash', done => {
- spyOn(integrationSettingsForm.$form, 'submit');
-
- const errorMessage = 'Test failed.';
- mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
- error: true,
- message: errorMessage,
- test_failed: true,
- });
-
- integrationSettingsForm
- .testSettings(formData)
- .then(() => {
- const $flashAction = $('.flash-container .flash-action');
-
- expect($flashAction).toBeDefined();
-
- $flashAction.get(0).click();
- })
- .then(() => {
- expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
-
- done();
- })
- .catch(done.fail);
- });
-
- it('should show error Flash if ajax request failed', done => {
- const errorMessage = 'Something went wrong on our end.';
-
- mock.onPut(integrationSettingsForm.testEndPoint).networkError();
-
- integrationSettingsForm
- .testSettings(formData)
- .then(() => {
- expect(
- $('.flash-container .flash-text')
- .text()
- .trim(),
- ).toEqual(errorMessage);
-
- done();
- })
- .catch(done.fail);
- });
-
- it('should always call `toggleSubmitBtnState` with `false` once request is completed', done => {
- mock.onPut(integrationSettingsForm.testEndPoint).networkError();
-
- spyOn(integrationSettingsForm, 'toggleSubmitBtnState');
-
- integrationSettingsForm
- .testSettings(formData)
- .then(() => {
- expect(integrationSettingsForm.toggleSubmitBtnState).toHaveBeenCalledWith(false);
-
- done();
- })
- .catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/issuable_spec.js b/spec/javascripts/issuable_spec.js
deleted file mode 100644
index 4d57bfb1b33..00000000000
--- a/spec/javascripts/issuable_spec.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import $ from 'jquery';
-import MockAdaptor from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
-import IssuableIndex from '~/issuable_index';
-import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
-
-describe('Issuable', () => {
- describe('initBulkUpdate', () => {
- it('should not set bulkUpdateSidebar', () => {
- new IssuableIndex('issue_'); // eslint-disable-line no-new
-
- expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeNull();
- });
-
- it('should set bulkUpdateSidebar', () => {
- const element = document.createElement('div');
- element.classList.add('issues-bulk-update');
- document.body.appendChild(element);
-
- new IssuableIndex('issue_'); // eslint-disable-line no-new
-
- expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeDefined();
- });
- });
-
- describe('resetIncomingEmailToken', () => {
- let mock;
-
- beforeEach(() => {
- const element = document.createElement('a');
- element.classList.add('incoming-email-token-reset');
- element.setAttribute('href', 'foo');
- document.body.appendChild(element);
-
- const input = document.createElement('input');
- input.setAttribute('id', 'issuable_email');
- document.body.appendChild(input);
-
- new IssuableIndex('issue_'); // eslint-disable-line no-new
-
- mock = new MockAdaptor(axios);
-
- mock.onPut('foo').reply(200, {
- new_address: 'testing123',
- });
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('should send request to reset email token', done => {
- spyOn(axios, 'put').and.callThrough();
- document.querySelector('.incoming-email-token-reset').click();
-
- setTimeout(() => {
- expect(axios.put).toHaveBeenCalledWith('foo');
- expect($('#issuable_email').val()).toBe('testing123');
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
deleted file mode 100644
index f11d4f5ac33..00000000000
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ /dev/null
@@ -1,568 +0,0 @@
-/* eslint-disable no-unused-vars */
-import Vue from 'vue';
-import MockAdapter from 'axios-mock-adapter';
-import setTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
-import GLDropdown from '~/gl_dropdown';
-import axios from '~/lib/utils/axios_utils';
-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';
-
-function formatText(text) {
- return text.trim().replace(/\s\s+/g, ' ');
-}
-
-const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
-
-describe('Issuable output', () => {
- let mock;
- let realtimeRequestCount = 0;
- let vm;
-
- beforeEach(done => {
- setFixtures(`
- <div>
- <div class="detail-page-description content-block">
- <details open>
- <summary>One</summary>
- </details>
- <details>
- <summary>Two</summary>
- </details>
- </div>
- <div class="flash-container"></div>
- <span id="task_status"></span>
- </div>
- `);
- spyOn(eventHub, '$emit');
-
- const IssuableDescriptionComponent = Vue.extend(issuableApp);
-
- mock = new MockAdapter(axios);
- mock
- .onGet('/gitlab-org/gitlab-shell/-/issues/9/realtime_changes/realtime_changes')
- .reply(() => {
- const res = Promise.resolve([200, REALTIME_REQUEST_STACK[realtimeRequestCount]]);
- realtimeRequestCount += 1;
- return res;
- });
-
- vm = new IssuableDescriptionComponent({
- propsData: {
- canUpdate: true,
- canDestroy: true,
- endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
- updateEndpoint: gl.TEST_HOST,
- issuableRef: '#1',
- initialTitleHtml: '',
- initialTitleText: '',
- initialDescriptionHtml: 'test',
- initialDescriptionText: 'test',
- lockVersion: 1,
- markdownPreviewPath: '/',
- markdownDocsPath: '/',
- projectNamespace: '/',
- projectPath: '/',
- issuableTemplateNamesPath: '/issuable-templates-path',
- },
- }).$mount();
-
- setTimeout(done);
- });
-
- afterEach(() => {
- mock.restore();
- realtimeRequestCount = 0;
-
- vm.poll.stop();
- vm.$destroy();
- });
-
- it('should render a title/description/edited and update title/description/edited on update', done => {
- let editedText;
- Vue.nextTick()
- .then(() => {
- editedText = vm.$el.querySelector('.edited-text');
- })
- .then(() => {
- expect(document.querySelector('title').innerText).toContain('this is a title (#1)');
- expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>');
- expect(vm.$el.querySelector('.md').innerHTML).toContain('<p>this is a description!</p>');
- expect(vm.$el.querySelector('.js-task-list-field').value).toContain(
- 'this is a description',
- );
-
- expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
- expect(editedText.querySelector('.author-link').href).toMatch(/\/some_user$/);
- expect(editedText.querySelector('time')).toBeTruthy();
- expect(vm.state.lock_version).toEqual(1);
- })
- .then(() => {
- vm.poll.makeRequest();
- })
- .then(() => new Promise(resolve => setTimeout(resolve)))
- .then(() => {
- expect(document.querySelector('title').innerText).toContain('2 (#1)');
- expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>');
- expect(vm.$el.querySelector('.md').innerHTML).toContain('<p>42</p>');
- expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42');
- expect(vm.$el.querySelector('.edited-text')).toBeTruthy();
- expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(
- /Edited[\s\S]+?by Other User/,
- );
-
- expect(editedText.querySelector('.author-link').href).toMatch(/\/other_user$/);
- expect(editedText.querySelector('time')).toBeTruthy();
- expect(vm.state.lock_version).toEqual(2);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('shows actions if permissions are correct', done => {
- vm.showForm = true;
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.btn')).not.toBeNull();
-
- done();
- });
- });
-
- it('does not show actions if permissions are incorrect', done => {
- vm.showForm = true;
- vm.canUpdate = false;
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.btn')).toBeNull();
-
- done();
- });
- });
-
- it('does not update formState if form is already open', done => {
- vm.updateAndShowForm();
-
- vm.state.titleText = 'testing 123';
-
- vm.updateAndShowForm();
-
- Vue.nextTick(() => {
- expect(vm.store.formState.title).not.toBe('testing 123');
-
- done();
- });
- });
-
- describe('updateIssuable', () => {
- it('fetches new data after update', done => {
- spyOn(vm, 'updateStoreState').and.callThrough();
- spyOn(vm.service, 'getData').and.callThrough();
- spyOn(vm.service, 'updateIssuable').and.returnValue(
- Promise.resolve({
- data: { web_url: window.location.pathname },
- }),
- );
-
- vm.updateIssuable()
- .then(() => {
- expect(vm.updateStoreState).toHaveBeenCalled();
- expect(vm.service.getData).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('correctly updates issuable data', done => {
- spyOn(vm.service, 'updateIssuable').and.returnValue(
- Promise.resolve({
- data: { web_url: window.location.pathname },
- }),
- );
-
- vm.updateIssuable()
- .then(() => {
- expect(vm.service.updateIssuable).toHaveBeenCalledWith(vm.formState);
- expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('does not redirect if issue has not moved', done => {
- const visitUrl = spyOnDependency(issuableApp, 'visitUrl');
- spyOn(vm.service, 'updateIssuable').and.returnValue(
- Promise.resolve({
- data: {
- web_url: window.location.pathname,
- confidential: vm.isConfidential,
- },
- }),
- );
-
- vm.updateIssuable();
-
- setTimeout(() => {
- expect(visitUrl).not.toHaveBeenCalled();
- done();
- });
- });
-
- it('redirects if returned web_url has changed', done => {
- const visitUrl = spyOnDependency(issuableApp, 'visitUrl');
- spyOn(vm.service, 'updateIssuable').and.returnValue(
- Promise.resolve({
- data: {
- web_url: '/testing-issue-move',
- confidential: vm.isConfidential,
- },
- }),
- );
-
- vm.updateIssuable();
-
- setTimeout(() => {
- expect(visitUrl).toHaveBeenCalledWith('/testing-issue-move');
- done();
- });
- });
-
- describe('shows dialog when issue has unsaved changed', () => {
- it('confirms on title change', done => {
- vm.showForm = true;
- vm.state.titleText = 'title has changed';
- const e = { returnValue: null };
- vm.handleBeforeUnloadEvent(e);
- Vue.nextTick(() => {
- expect(e.returnValue).not.toBeNull();
-
- done();
- });
- });
-
- it('confirms on description change', done => {
- vm.showForm = true;
- vm.state.descriptionText = 'description has changed';
- const e = { returnValue: null };
- vm.handleBeforeUnloadEvent(e);
- Vue.nextTick(() => {
- expect(e.returnValue).not.toBeNull();
-
- done();
- });
- });
-
- it('does nothing when nothing has changed', done => {
- const e = { returnValue: null };
- vm.handleBeforeUnloadEvent(e);
- Vue.nextTick(() => {
- expect(e.returnValue).toBeNull();
-
- done();
- });
- });
- });
-
- describe('error when updating', () => {
- it('closes form on error', done => {
- spyOn(vm.service, 'updateIssuable').and.callFake(() => Promise.reject());
- vm.updateIssuable();
-
- setTimeout(() => {
- expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
- expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
- `Error updating issue`,
- );
-
- done();
- });
- });
-
- it('returns the correct error message for issuableType', done => {
- spyOn(vm.service, 'updateIssuable').and.callFake(() => Promise.reject());
- vm.issuableType = 'merge request';
-
- Vue.nextTick(() => {
- vm.updateIssuable();
-
- setTimeout(() => {
- expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
- expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
- `Error updating merge request`,
- );
-
- done();
- });
- });
- });
-
- it('shows error message from backend if exists', done => {
- const msg = 'Custom error message from backend';
- spyOn(vm.service, 'updateIssuable').and.callFake(
- // eslint-disable-next-line prefer-promise-reject-errors
- () => Promise.reject({ response: { data: { errors: [msg] } } }),
- );
-
- vm.updateIssuable();
- setTimeout(() => {
- expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
- `${vm.defaultErrorMessage}. ${msg}`,
- );
-
- done();
- });
- });
- });
- });
-
- it('opens recaptcha modal if update rejected as spam', done => {
- function mockScriptSrc() {
- const recaptchaChild = vm.$children.find(
- // eslint-disable-next-line no-underscore-dangle
- child => child.$options._componentTag === 'recaptcha-modal',
- );
-
- recaptchaChild.scriptSrc = '//scriptsrc';
- }
-
- let modal;
- const promise = new Promise(resolve => {
- resolve({
- data: {
- recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>',
- },
- });
- });
-
- spyOn(vm.service, 'updateIssuable').and.returnValue(promise);
-
- vm.canUpdate = true;
- vm.showForm = true;
-
- vm.$nextTick()
- .then(() => mockScriptSrc())
- .then(() => vm.updateIssuable())
- .then(promise)
- .then(() => setTimeoutPromise())
- .then(() => {
- modal = vm.$el.querySelector('.js-recaptcha-modal');
-
- expect(modal.style.display).not.toEqual('none');
- expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html');
- expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc');
- })
- .then(() => modal.querySelector('.close').click())
- .then(() => vm.$nextTick())
- .then(() => {
- expect(modal.style.display).toEqual('none');
- expect(document.body.querySelector('.js-recaptcha-script')).toBeNull();
- })
- .then(done)
- .catch(done.fail);
- });
-
- describe('deleteIssuable', () => {
- it('changes URL when deleted', done => {
- const visitUrl = spyOnDependency(issuableApp, 'visitUrl');
- spyOn(vm.service, 'deleteIssuable').and.returnValue(
- Promise.resolve({
- data: {
- web_url: '/test',
- },
- }),
- );
-
- vm.deleteIssuable();
-
- setTimeout(() => {
- expect(visitUrl).toHaveBeenCalledWith('/test');
-
- done();
- });
- });
-
- it('stops polling when deleting', done => {
- spyOnDependency(issuableApp, 'visitUrl');
- spyOn(vm.poll, 'stop').and.callThrough();
- spyOn(vm.service, 'deleteIssuable').and.returnValue(
- Promise.resolve({
- data: {
- web_url: '/test',
- },
- }),
- );
-
- vm.deleteIssuable();
-
- setTimeout(() => {
- expect(vm.poll.stop).toHaveBeenCalledWith();
-
- done();
- });
- });
-
- it('closes form on error', done => {
- spyOn(vm.service, 'deleteIssuable').and.returnValue(Promise.reject());
-
- vm.deleteIssuable();
-
- setTimeout(() => {
- expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
- expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
- 'Error deleting issue',
- );
-
- done();
- });
- });
- });
-
- describe('updateAndShowForm', () => {
- it('shows locked warning if form is open & data is different', done => {
- vm.$nextTick()
- .then(() => {
- vm.updateAndShowForm();
-
- vm.poll.makeRequest();
-
- return new Promise(resolve => {
- vm.$watch('formState.lockedWarningVisible', value => {
- if (value) resolve();
- });
- });
- })
- .then(() => {
- expect(vm.formState.lockedWarningVisible).toEqual(true);
- expect(vm.formState.lock_version).toEqual(1);
- expect(vm.$el.querySelector('.alert')).not.toBeNull();
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
- describe('requestTemplatesAndShowForm', () => {
- beforeEach(() => {
- spyOn(vm, 'updateAndShowForm');
- });
-
- it('shows the form if template names request is successful', done => {
- const mockData = [{ name: 'Bug' }];
- mock.onGet('/issuable-templates-path').reply(() => Promise.resolve([200, mockData]));
-
- vm.requestTemplatesAndShowForm()
- .then(() => {
- expect(vm.updateAndShowForm).toHaveBeenCalledWith(mockData);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('shows the form if template names request failed', done => {
- mock
- .onGet('/issuable-templates-path')
- .reply(() => Promise.reject(new Error('something went wrong')));
-
- vm.requestTemplatesAndShowForm()
- .then(() => {
- expect(document.querySelector('.flash-container .flash-text').textContent).toContain(
- 'Error updating issue',
- );
-
- expect(vm.updateAndShowForm).toHaveBeenCalledWith();
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
- describe('show inline edit button', () => {
- it('should not render by default', () => {
- expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined();
- });
-
- it('should render if showInlineEditButton', () => {
- vm.showInlineEditButton = true;
-
- expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined();
- });
- });
-
- describe('updateStoreState', () => {
- it('should make a request and update the state of the store', done => {
- const data = { foo: 1 };
- spyOn(vm.store, 'updateState');
- spyOn(vm.service, 'getData').and.returnValue(Promise.resolve({ data }));
-
- vm.updateStoreState()
- .then(() => {
- expect(vm.service.getData).toHaveBeenCalled();
- expect(vm.store.updateState).toHaveBeenCalledWith(data);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('should show error message if store update fails', done => {
- spyOn(vm.service, 'getData').and.returnValue(Promise.reject());
- vm.issuableType = 'merge request';
-
- vm.updateStoreState()
- .then(() => {
- expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
- `Error updating ${vm.issuableType}`,
- );
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
- describe('issueChanged', () => {
- beforeEach(() => {
- vm.store.formState.title = '';
- vm.store.formState.description = '';
- vm.initialDescriptionText = '';
- vm.initialTitleText = '';
- });
-
- it('returns true when title is changed', () => {
- vm.store.formState.title = 'RandomText';
-
- expect(vm.issueChanged).toBe(true);
- });
-
- it('returns false when title is empty null', () => {
- vm.store.formState.title = null;
-
- expect(vm.issueChanged).toBe(false);
- });
-
- it('returns false when `initialTitleText` is null and `formState.title` is empty string', () => {
- vm.store.formState.title = '';
- vm.initialTitleText = null;
-
- expect(vm.issueChanged).toBe(false);
- });
-
- it('returns true when description is changed', () => {
- vm.store.formState.description = 'RandomText';
-
- expect(vm.issueChanged).toBe(true);
- });
-
- it('returns false when description is empty null', () => {
- vm.store.formState.title = null;
-
- expect(vm.issueChanged).toBe(false);
- });
-
- it('returns false when `initialDescriptionText` is null and `formState.description` is empty string', () => {
- vm.store.formState.description = '';
- vm.initialDescriptionText = null;
-
- expect(vm.issueChanged).toBe(false);
- });
- });
-});
diff --git a/spec/javascripts/issue_show/components/description_spec.js b/spec/javascripts/issue_show/components/description_spec.js
deleted file mode 100644
index 83e498347f7..00000000000
--- a/spec/javascripts/issue_show/components/description_spec.js
+++ /dev/null
@@ -1,210 +0,0 @@
-import $ from 'jquery';
-import Vue from 'vue';
-import '~/behaviors/markdown/render_gfm';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import Description from '~/issue_show/components/description.vue';
-
-describe('Description component', () => {
- let vm;
- let DescriptionComponent;
- const props = {
- canUpdate: true,
- descriptionHtml: 'test',
- descriptionText: 'test',
- updatedAt: new Date().toString(),
- taskStatus: '',
- updateUrl: gl.TEST_HOST,
- };
-
- beforeEach(() => {
- DescriptionComponent = Vue.extend(Description);
-
- if (!document.querySelector('.issuable-meta')) {
- const metaData = document.createElement('div');
- metaData.classList.add('issuable-meta');
- metaData.innerHTML =
- '<div class="flash-container"></div><span id="task_status"></span><span id="task_status_short"></span>';
-
- document.body.appendChild(metaData);
- }
-
- vm = mountComponent(DescriptionComponent, props);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- afterAll(() => {
- $('.issuable-meta .flash-container').remove();
- });
-
- it('animates description changes', done => {
- vm.descriptionHtml = 'changed';
-
- Vue.nextTick(() => {
- expect(
- vm.$el.querySelector('.md').classList.contains('issue-realtime-pre-pulse'),
- ).toBeTruthy();
-
- setTimeout(() => {
- expect(
- vm.$el.querySelector('.md').classList.contains('issue-realtime-trigger-pulse'),
- ).toBeTruthy();
-
- done();
- });
- });
- });
-
- it('opens recaptcha dialog if update rejected as spam', done => {
- let modal;
- const recaptchaChild = vm.$children.find(
- // eslint-disable-next-line no-underscore-dangle
- child => child.$options._componentTag === 'recaptcha-modal',
- );
-
- recaptchaChild.scriptSrc = '//scriptsrc';
-
- vm.taskListUpdateSuccess({
- recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>',
- });
-
- vm.$nextTick()
- .then(() => {
- modal = vm.$el.querySelector('.js-recaptcha-modal');
-
- expect(modal.style.display).not.toEqual('none');
- expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html');
- expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc');
- })
- .then(() => modal.querySelector('.close').click())
- .then(() => vm.$nextTick())
- .then(() => {
- expect(modal.style.display).toEqual('none');
- expect(document.body.querySelector('.js-recaptcha-script')).toBeNull();
- })
- .then(done)
- .catch(done.fail);
- });
-
- describe('TaskList', () => {
- let TaskList;
-
- beforeEach(() => {
- vm.$destroy();
- vm = mountComponent(
- DescriptionComponent,
- Object.assign({}, props, {
- issuableType: 'issuableType',
- }),
- );
- TaskList = spyOnDependency(Description, 'TaskList');
- });
-
- it('re-inits the TaskList when description changed', done => {
- vm.descriptionHtml = 'changed';
-
- setTimeout(() => {
- expect(TaskList).toHaveBeenCalled();
- done();
- });
- });
-
- it('does not re-init the TaskList when canUpdate is false', done => {
- vm.canUpdate = false;
- vm.descriptionHtml = 'changed';
-
- setTimeout(() => {
- expect(TaskList).not.toHaveBeenCalled();
- done();
- });
- });
-
- it('calls with issuableType dataType', done => {
- vm.descriptionHtml = 'changed';
-
- setTimeout(() => {
- expect(TaskList).toHaveBeenCalledWith({
- dataType: 'issuableType',
- fieldName: 'description',
- selector: '.detail-page-description',
- onSuccess: jasmine.any(Function),
- onError: jasmine.any(Function),
- lockVersion: 0,
- });
-
- done();
- });
- });
- });
-
- describe('taskStatus', () => {
- it('adds full taskStatus', done => {
- vm.taskStatus = '1 of 1';
-
- setTimeout(() => {
- expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe(
- '1 of 1',
- );
-
- done();
- });
- });
-
- it('adds short taskStatus', done => {
- vm.taskStatus = '1 of 1';
-
- setTimeout(() => {
- expect(document.querySelector('.issuable-meta #task_status_short').textContent.trim()).toBe(
- '1/1 task',
- );
-
- done();
- });
- });
-
- it('clears task status text when no tasks are present', done => {
- vm.taskStatus = '0 of 0';
-
- setTimeout(() => {
- expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe('');
-
- done();
- });
- });
- });
-
- it('applies syntax highlighting and math when description changed', done => {
- spyOn(vm, 'renderGFM').and.callThrough();
- spyOn($.prototype, 'renderGFM').and.callThrough();
- vm.descriptionHtml = 'changed';
-
- Vue.nextTick(() => {
- setTimeout(() => {
- expect(vm.$refs['gfm-content']).toBeDefined();
- expect(vm.renderGFM).toHaveBeenCalled();
- expect($.prototype.renderGFM).toHaveBeenCalled();
-
- done();
- });
- });
- });
-
- it('sets data-update-url', () => {
- expect(vm.$el.querySelector('textarea').dataset.updateUrl).toEqual(gl.TEST_HOST);
- });
-
- describe('taskListUpdateError', () => {
- it('should create flash notification and emit an event to parent', () => {
- const msg =
- 'Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again.';
- spyOn(vm, '$emit');
-
- vm.taskListUpdateError();
-
- expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg);
- expect(vm.$emit).toHaveBeenCalledWith('taskListUpdateFailed');
- });
- });
-});
diff --git a/spec/javascripts/issue_show/components/fields/description_template_spec.js b/spec/javascripts/issue_show/components/fields/description_template_spec.js
deleted file mode 100644
index 8d77a620d76..00000000000
--- a/spec/javascripts/issue_show/components/fields/description_template_spec.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import Vue from 'vue';
-import descriptionTemplate from '~/issue_show/components/fields/description_template.vue';
-
-describe('Issue description template component', () => {
- let vm;
- let formState;
-
- beforeEach(done => {
- const Component = Vue.extend(descriptionTemplate);
- formState = {
- description: 'test',
- };
-
- vm = new Component({
- propsData: {
- formState,
- issuableTemplates: [{ name: 'test' }],
- projectPath: '/',
- projectNamespace: '/',
- },
- }).$mount();
-
- Vue.nextTick(done);
- });
-
- it('renders templates as JSON array in data attribute', () => {
- expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe(
- '[{"name":"test"}]',
- );
- });
-
- it('updates formState when changing template', () => {
- vm.issuableTemplate.editor.setValue('test new template');
-
- expect(formState.description).toBe('test new template');
- });
-
- it('returns formState description with editor getValue', () => {
- formState.description = 'testing new template';
-
- expect(vm.issuableTemplate.editor.getValue()).toBe('testing new template');
- });
-});
diff --git a/spec/javascripts/issue_show/components/form_spec.js b/spec/javascripts/issue_show/components/form_spec.js
deleted file mode 100644
index a111333ac80..00000000000
--- a/spec/javascripts/issue_show/components/form_spec.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import formComponent from '~/issue_show/components/form.vue';
-import eventHub from '~/issue_show/event_hub';
-
-describe('Inline edit form component', () => {
- let vm;
- const defaultProps = {
- canDestroy: true,
- formState: {
- title: 'b',
- description: 'a',
- lockedWarningVisible: false,
- },
- issuableType: 'issue',
- markdownPreviewPath: '/',
- markdownDocsPath: '/',
- projectPath: '/',
- projectNamespace: '/',
- };
-
- afterEach(() => {
- vm.$destroy();
- });
-
- const createComponent = props => {
- const Component = Vue.extend(formComponent);
-
- vm = mountComponent(Component, {
- ...defaultProps,
- ...props,
- });
- };
-
- it('does not render template selector if no templates exist', () => {
- createComponent();
-
- expect(vm.$el.querySelector('.js-issuable-selector-wrap')).toBeNull();
- });
-
- it('renders template selector when templates exists', () => {
- createComponent({ issuableTemplates: ['test'] });
-
- expect(vm.$el.querySelector('.js-issuable-selector-wrap')).not.toBeNull();
- });
-
- it('hides locked warning by default', () => {
- createComponent();
-
- expect(vm.$el.querySelector('.alert')).toBeNull();
- });
-
- it('shows locked warning if formState is different', () => {
- createComponent({ formState: { ...defaultProps.formState, lockedWarningVisible: true } });
-
- expect(vm.$el.querySelector('.alert')).not.toBeNull();
- });
-
- it('hides locked warning when currently saving', () => {
- createComponent({
- formState: { ...defaultProps.formState, updateLoading: true, lockedWarningVisible: true },
- });
-
- expect(vm.$el.querySelector('.alert')).toBeNull();
- });
-
- describe('autosave', () => {
- let autosaveObj;
- let autosave;
-
- beforeEach(() => {
- autosaveObj = { reset: jasmine.createSpy() };
- autosave = spyOnDependency(formComponent, 'Autosave').and.returnValue(autosaveObj);
- });
-
- it('initialized Autosave on mount', () => {
- createComponent();
-
- expect(autosave).toHaveBeenCalledTimes(2);
- });
-
- it('calls reset on autosave when eventHub emits appropriate events', () => {
- createComponent();
-
- eventHub.$emit('close.form');
-
- expect(autosaveObj.reset).toHaveBeenCalledTimes(2);
-
- eventHub.$emit('delete.issuable');
-
- expect(autosaveObj.reset).toHaveBeenCalledTimes(4);
-
- eventHub.$emit('update.issuable');
-
- expect(autosaveObj.reset).toHaveBeenCalledTimes(6);
- });
- });
-});
diff --git a/spec/javascripts/issue_show/components/title_spec.js b/spec/javascripts/issue_show/components/title_spec.js
deleted file mode 100644
index 9754c8a6755..00000000000
--- a/spec/javascripts/issue_show/components/title_spec.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import Vue from 'vue';
-import Store from '~/issue_show/stores';
-import titleComponent from '~/issue_show/components/title.vue';
-import eventHub from '~/issue_show/event_hub';
-
-describe('Title component', () => {
- let vm;
-
- beforeEach(() => {
- const Component = Vue.extend(titleComponent);
- const store = new Store({
- titleHtml: '',
- descriptionHtml: '',
- issuableRef: '',
- });
- vm = new Component({
- propsData: {
- issuableRef: '#1',
- titleHtml: 'Testing <img />',
- titleText: 'Testing',
- showForm: false,
- formState: store.formState,
- },
- }).$mount();
- });
-
- it('renders title HTML', () => {
- expect(vm.$el.querySelector('.title').innerHTML.trim()).toBe('Testing <img>');
- });
-
- it('updates page title when changing titleHtml', done => {
- spyOn(vm, 'setPageTitle');
- vm.titleHtml = 'test';
-
- Vue.nextTick(() => {
- expect(vm.setPageTitle).toHaveBeenCalled();
-
- done();
- });
- });
-
- it('animates title changes', done => {
- vm.titleHtml = 'test';
-
- Vue.nextTick(() => {
- expect(
- vm.$el.querySelector('.title').classList.contains('issue-realtime-pre-pulse'),
- ).toBeTruthy();
-
- setTimeout(() => {
- expect(
- vm.$el.querySelector('.title').classList.contains('issue-realtime-trigger-pulse'),
- ).toBeTruthy();
-
- done();
- });
- });
- });
-
- it('updates page title after changing title', done => {
- vm.titleHtml = 'changed';
- vm.titleText = 'changed';
-
- Vue.nextTick(() => {
- expect(document.querySelector('title').textContent.trim()).toContain('changed');
-
- done();
- });
- });
-
- describe('inline edit button', () => {
- beforeEach(() => {
- spyOn(eventHub, '$emit');
- });
-
- it('should not show by default', () => {
- expect(vm.$el.querySelector('.btn-edit')).toBeNull();
- });
-
- it('should not show if canUpdate is false', () => {
- vm.showInlineEditButton = true;
- vm.canUpdate = false;
-
- expect(vm.$el.querySelector('.btn-edit')).toBeNull();
- });
-
- it('should show if showInlineEditButton and canUpdate', () => {
- vm.showInlineEditButton = true;
- vm.canUpdate = true;
-
- expect(vm.$el.querySelector('.btn-edit')).toBeDefined();
- });
-
- it('should trigger open.form event when clicked', () => {
- vm.showInlineEditButton = true;
- vm.canUpdate = true;
-
- Vue.nextTick(() => {
- vm.$el.querySelector('.btn-edit').click();
-
- expect(eventHub.$emit).toHaveBeenCalledWith('open.form');
- });
- });
- });
-});
diff --git a/spec/javascripts/issue_show/helpers.js b/spec/javascripts/issue_show/helpers.js
deleted file mode 100644
index 951acfd4e10..00000000000
--- a/spec/javascripts/issue_show/helpers.js
+++ /dev/null
@@ -1 +0,0 @@
-export * from '../../frontend/issue_show/helpers.js';
diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js
deleted file mode 100644
index 1b391bd1588..00000000000
--- a/spec/javascripts/issue_show/mock_data.js
+++ /dev/null
@@ -1 +0,0 @@
-export * from '../../frontend/issue_show/mock_data';
diff --git a/spec/javascripts/jobs/components/commit_block_spec.js b/spec/javascripts/jobs/components/commit_block_spec.js
deleted file mode 100644
index c02f564d01a..00000000000
--- a/spec/javascripts/jobs/components/commit_block_spec.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import Vue from 'vue';
-import component from '~/jobs/components/commit_block.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
-
-describe('Commit block', () => {
- const Component = Vue.extend(component);
- let vm;
-
- const props = {
- commit: {
- short_id: '1f0fb84f',
- id: '1f0fb84fb6770d74d97eee58118fd3909cd4f48c',
- commit_path: 'commit/1f0fb84fb6770d74d97eee58118fd3909cd4f48c',
- title: 'Update README.md',
- },
- mergeRequest: {
- iid: '!21244',
- path: 'merge_requests/21244',
- },
- isLastBlock: true,
- };
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('pipeline short sha', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- ...props,
- });
- });
-
- it('renders pipeline short sha link', () => {
- expect(vm.$el.querySelector('.js-commit-sha').getAttribute('href')).toEqual(
- props.commit.commit_path,
- );
-
- expect(vm.$el.querySelector('.js-commit-sha').textContent.trim()).toEqual(
- props.commit.short_id,
- );
- });
-
- it('renders clipboard button', () => {
- expect(vm.$el.querySelector('button').getAttribute('data-clipboard-text')).toEqual(
- props.commit.id,
- );
- });
- });
-
- describe('with merge request', () => {
- it('renders merge request link and reference', () => {
- vm = mountComponent(Component, {
- ...props,
- });
-
- expect(vm.$el.querySelector('.js-link-commit').getAttribute('href')).toEqual(
- props.mergeRequest.path,
- );
-
- expect(vm.$el.querySelector('.js-link-commit').textContent.trim()).toEqual(
- `!${props.mergeRequest.iid}`,
- );
- });
- });
-
- describe('without merge request', () => {
- it('does not render merge request', () => {
- const copyProps = Object.assign({}, props);
- delete copyProps.mergeRequest;
-
- vm = mountComponent(Component, {
- ...copyProps,
- });
-
- expect(vm.$el.querySelector('.js-link-commit')).toBeNull();
- });
- });
-
- describe('git commit title', () => {
- it('renders git commit title', () => {
- vm = mountComponent(Component, {
- ...props,
- });
-
- expect(vm.$el.textContent).toContain(props.commit.title);
- });
- });
-});
diff --git a/spec/javascripts/jobs/components/job_container_item_spec.js b/spec/javascripts/jobs/components/job_container_item_spec.js
deleted file mode 100644
index 99f6d9a14d9..00000000000
--- a/spec/javascripts/jobs/components/job_container_item_spec.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import JobContainerItem from '~/jobs/components/job_container_item.vue';
-import job from '../mock_data';
-
-describe('JobContainerItem', () => {
- const delayedJobFixture = getJSONFixture('jobs/delayed.json');
- const Component = Vue.extend(JobContainerItem);
- let vm;
-
- afterEach(() => {
- vm.$destroy();
- });
-
- const sharedTests = () => {
- it('displays a status icon', () => {
- expect(vm.$el).toHaveSpriteIcon(job.status.icon);
- });
-
- it('displays the job name', () => {
- expect(vm.$el).toContainText(job.name);
- });
-
- it('displays a link to the job', () => {
- const link = vm.$el.querySelector('.js-job-link');
-
- expect(link.href).toBe(job.status.details_path);
- });
- };
-
- describe('when a job is not active and not retied', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- job,
- isActive: false,
- });
- });
-
- sharedTests();
- });
-
- describe('when a job is active', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- job,
- isActive: true,
- });
- });
-
- sharedTests();
-
- it('displays an arrow', () => {
- expect(vm.$el).toHaveSpriteIcon('arrow-right');
- });
- });
-
- describe('when a job is retried', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- job: {
- ...job,
- retried: true,
- },
- isActive: false,
- });
- });
-
- sharedTests();
-
- it('displays an icon', () => {
- expect(vm.$el).toHaveSpriteIcon('retry');
- });
- });
-
- describe('for delayed job', () => {
- beforeEach(() => {
- const remainingMilliseconds = 1337000;
- spyOn(Date, 'now').and.callFake(
- () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingMilliseconds,
- );
- });
-
- it('displays remaining time in tooltip', done => {
- vm = mountComponent(Component, {
- job: delayedJobFixture,
- isActive: false,
- });
-
- Vue.nextTick()
- .then(() => {
- expect(vm.$el.querySelector('.js-job-link').getAttribute('data-original-title')).toEqual(
- 'delayed job - delayed manual action (00:22:17)',
- );
- })
- .then(done)
- .catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/jobs/components/job_log_spec.js b/spec/javascripts/jobs/components/job_log_spec.js
deleted file mode 100644
index fcaf2b3bb64..00000000000
--- a/spec/javascripts/jobs/components/job_log_spec.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import Vue from 'vue';
-import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import component from '~/jobs/components/job_log.vue';
-import createStore from '~/jobs/store';
-import { resetStore } from '../store/helpers';
-
-describe('Job Log', () => {
- const Component = Vue.extend(component);
- let store;
- let vm;
-
- const trace =
- '<span>Running with gitlab-runner 12.1.0 (de7731dd)<br/></span><span> on docker-auto-scale-com d5ae8d25<br/></span><div class="append-right-8" data-timestamp="1565502765" data-section="prepare-executor" role="button"></div><span class="section section-header js-s-prepare-executor">Using Docker executor with image ruby:2.6 ...<br/></span>';
-
- beforeEach(() => {
- store = createStore();
- });
-
- afterEach(() => {
- resetStore(store);
- vm.$destroy();
- });
-
- it('renders provided trace', () => {
- vm = mountComponentWithStore(Component, {
- props: {
- trace,
- isComplete: true,
- },
- store,
- });
-
- expect(vm.$el.querySelector('code').textContent).toContain(
- 'Running with gitlab-runner 12.1.0 (de7731dd)',
- );
- });
-
- describe('while receiving trace', () => {
- it('renders animation', () => {
- vm = mountComponentWithStore(Component, {
- props: {
- trace,
- isComplete: false,
- },
- store,
- });
-
- expect(vm.$el.querySelector('.js-log-animation')).not.toBeNull();
- });
- });
-
- describe('when build trace has finishes', () => {
- it('does not render animation', () => {
- vm = mountComponentWithStore(Component, {
- props: {
- trace,
- isComplete: true,
- },
- store,
- });
-
- expect(vm.$el.querySelector('.js-log-animation')).toBeNull();
- });
- });
-});
diff --git a/spec/javascripts/jobs/components/sidebar_spec.js b/spec/javascripts/jobs/components/sidebar_spec.js
deleted file mode 100644
index 740bc3d0491..00000000000
--- a/spec/javascripts/jobs/components/sidebar_spec.js
+++ /dev/null
@@ -1,169 +0,0 @@
-import Vue from 'vue';
-import sidebarDetailsBlock from '~/jobs/components/sidebar.vue';
-import createStore from '~/jobs/store';
-import job, { jobsInStage } from '../mock_data';
-import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper';
-import { trimText } from '../../helpers/text_helper';
-
-describe('Sidebar details block', () => {
- const SidebarComponent = Vue.extend(sidebarDetailsBlock);
- let vm;
- let store;
-
- beforeEach(() => {
- store = createStore();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('when there is no retry path retry', () => {
- it('should not render a retry button', () => {
- const copy = Object.assign({}, job);
- delete copy.retry_path;
-
- store.dispatch('receiveJobSuccess', copy);
- vm = mountComponentWithStore(SidebarComponent, {
- store,
- });
-
- expect(vm.$el.querySelector('.js-retry-button')).toBeNull();
- });
- });
-
- describe('without terminal path', () => {
- it('does not render terminal link', () => {
- store.dispatch('receiveJobSuccess', job);
- vm = mountComponentWithStore(SidebarComponent, { store });
-
- expect(vm.$el.querySelector('.js-terminal-link')).toBeNull();
- });
- });
-
- describe('with terminal path', () => {
- it('renders terminal link', () => {
- store.dispatch(
- 'receiveJobSuccess',
- Object.assign({}, job, { terminal_path: 'job/43123/terminal' }),
- );
- vm = mountComponentWithStore(SidebarComponent, {
- store,
- });
-
- expect(vm.$el.querySelector('.js-terminal-link')).not.toBeNull();
- });
- });
-
- beforeEach(() => {
- store.dispatch('receiveJobSuccess', job);
- vm = mountComponentWithStore(SidebarComponent, { store });
- });
-
- describe('actions', () => {
- it('should render link to new issue', () => {
- expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual(
- job.new_issue_path,
- );
-
- expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue');
- });
-
- it('should render link to retry job', () => {
- expect(vm.$el.querySelector('.js-retry-button').getAttribute('href')).toEqual(job.retry_path);
- });
-
- it('should render link to cancel job', () => {
- expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(job.cancel_path);
- });
- });
-
- describe('information', () => {
- it('should render job duration', () => {
- expect(trimText(vm.$el.querySelector('.js-job-duration').textContent)).toEqual(
- 'Duration: 6 seconds',
- );
- });
-
- it('should render erased date', () => {
- expect(trimText(vm.$el.querySelector('.js-job-erased').textContent)).toEqual(
- 'Erased: 3 weeks ago',
- );
- });
-
- it('should render finished date', () => {
- expect(trimText(vm.$el.querySelector('.js-job-finished').textContent)).toEqual(
- 'Finished: 3 weeks ago',
- );
- });
-
- it('should render queued date', () => {
- expect(trimText(vm.$el.querySelector('.js-job-queued').textContent)).toEqual(
- 'Queued: 9 seconds',
- );
- });
-
- it('should render runner ID', () => {
- expect(trimText(vm.$el.querySelector('.js-job-runner').textContent)).toEqual(
- 'Runner: local ci runner (#1)',
- );
- });
-
- it('should render timeout information', () => {
- expect(trimText(vm.$el.querySelector('.js-job-timeout').textContent)).toEqual(
- 'Timeout: 1m 40s (from runner)',
- );
- });
-
- it('should render coverage', () => {
- expect(trimText(vm.$el.querySelector('.js-job-coverage').textContent)).toEqual(
- 'Coverage: 20%',
- );
- });
-
- it('should render tags', () => {
- expect(trimText(vm.$el.querySelector('.js-job-tags').textContent)).toEqual('Tags: tag');
- });
- });
-
- describe('stages dropdown', () => {
- beforeEach(() => {
- store.dispatch('receiveJobSuccess', job);
- });
-
- describe('with stages', () => {
- beforeEach(() => {
- vm = mountComponentWithStore(SidebarComponent, { store });
- });
-
- it('renders value provided as selectedStage as selected', () => {
- expect(vm.$el.querySelector('.js-selected-stage').textContent.trim()).toEqual(
- vm.selectedStage,
- );
- });
- });
-
- describe('without jobs for stages', () => {
- beforeEach(() => {
- store.dispatch('receiveJobSuccess', job);
- vm = mountComponentWithStore(SidebarComponent, { store });
- });
-
- it('does not render job container', () => {
- expect(vm.$el.querySelector('.js-jobs-container')).toBeNull();
- });
- });
-
- describe('with jobs for stages', () => {
- beforeEach(() => {
- store.dispatch('receiveJobSuccess', job);
- store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses);
- vm = mountComponentWithStore(SidebarComponent, { store });
- });
-
- it('renders list of jobs', () => {
- expect(vm.$el.querySelector('.js-jobs-container')).not.toBeNull();
- });
- });
- });
-});
diff --git a/spec/javascripts/jobs/components/stages_dropdown_spec.js b/spec/javascripts/jobs/components/stages_dropdown_spec.js
deleted file mode 100644
index f1a01530104..00000000000
--- a/spec/javascripts/jobs/components/stages_dropdown_spec.js
+++ /dev/null
@@ -1,163 +0,0 @@
-import Vue from 'vue';
-import { trimText } from 'spec/helpers/text_helper';
-import component from '~/jobs/components/stages_dropdown.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
-
-describe('Stages Dropdown', () => {
- const Component = Vue.extend(component);
- let vm;
-
- const mockPipelineData = {
- id: 28029444,
- details: {
- status: {
- details_path: '/gitlab-org/gitlab-foss/pipelines/28029444',
- group: 'success',
- has_details: true,
- icon: 'status_success',
- label: 'passed',
- text: 'passed',
- tooltip: 'passed',
- },
- },
- path: 'pipeline/28029444',
- flags: {
- merge_request_pipeline: true,
- detached_merge_request_pipeline: false,
- },
- merge_request: {
- iid: 1234,
- path: '/root/detached-merge-request-pipelines/-/merge_requests/1',
- title: 'Update README.md',
- source_branch: 'feature-1234',
- source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1234',
- target_branch: 'master',
- target_branch_path: '/root/detached-merge-request-pipelines/branches/master',
- },
- ref: {
- name: 'test-branch',
- },
- };
-
- describe('without a merge request pipeline', () => {
- let pipeline;
-
- beforeEach(() => {
- pipeline = JSON.parse(JSON.stringify(mockPipelineData));
- delete pipeline.merge_request;
- delete pipeline.flags.merge_request_pipeline;
- delete pipeline.flags.detached_merge_request_pipeline;
-
- vm = mountComponent(Component, {
- pipeline,
- stages: [{ name: 'build' }, { name: 'test' }],
- selectedStage: 'deploy',
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders pipeline status', () => {
- expect(vm.$el.querySelector('.js-ci-status-icon-success')).not.toBeNull();
- });
-
- it('renders pipeline link', () => {
- expect(vm.$el.querySelector('.js-pipeline-path').getAttribute('href')).toEqual(
- 'pipeline/28029444',
- );
- });
-
- it('renders dropdown with stages', () => {
- expect(vm.$el.querySelector('.dropdown .js-stage-item').textContent).toContain('build');
- });
-
- it('rendes selected stage', () => {
- expect(vm.$el.querySelector('.dropdown .js-selected-stage').textContent).toContain('deploy');
- });
-
- it(`renders the pipeline info text like "Pipeline #123 for source_branch"`, () => {
- const expected = `Pipeline #${pipeline.id} for ${pipeline.ref.name}`;
- const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText);
-
- expect(actual).toBe(expected);
- });
- });
-
- describe('with an "attached" merge request pipeline', () => {
- let pipeline;
-
- beforeEach(() => {
- pipeline = JSON.parse(JSON.stringify(mockPipelineData));
- pipeline.flags.merge_request_pipeline = true;
- pipeline.flags.detached_merge_request_pipeline = false;
-
- vm = mountComponent(Component, {
- pipeline,
- stages: [],
- selectedStage: 'deploy',
- });
- });
-
- it(`renders the pipeline info text like "Pipeline #123 for !456 with source_branch into target_branch"`, () => {
- const expected = `Pipeline #${pipeline.id} for !${pipeline.merge_request.iid} with ${pipeline.merge_request.source_branch} into ${pipeline.merge_request.target_branch}`;
- const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText);
-
- expect(actual).toBe(expected);
- });
-
- it(`renders the correct merge request link`, () => {
- const actual = vm.$el.querySelector('.js-mr-link').href;
-
- expect(actual).toContain(pipeline.merge_request.path);
- });
-
- it(`renders the correct source branch link`, () => {
- const actual = vm.$el.querySelector('.js-source-branch-link').href;
-
- expect(actual).toContain(pipeline.merge_request.source_branch_path);
- });
-
- it(`renders the correct target branch link`, () => {
- const actual = vm.$el.querySelector('.js-target-branch-link').href;
-
- expect(actual).toContain(pipeline.merge_request.target_branch_path);
- });
- });
-
- describe('with a detached merge request pipeline', () => {
- let pipeline;
-
- beforeEach(() => {
- pipeline = JSON.parse(JSON.stringify(mockPipelineData));
- pipeline.flags.merge_request_pipeline = false;
- pipeline.flags.detached_merge_request_pipeline = true;
-
- vm = mountComponent(Component, {
- pipeline,
- stages: [],
- selectedStage: 'deploy',
- });
- });
-
- it(`renders the pipeline info like "Pipeline #123 for !456 with source_branch"`, () => {
- const expected = `Pipeline #${pipeline.id} for !${pipeline.merge_request.iid} with ${pipeline.merge_request.source_branch}`;
- const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText);
-
- expect(actual).toBe(expected);
- });
-
- it(`renders the correct merge request link`, () => {
- const actual = vm.$el.querySelector('.js-mr-link').href;
-
- expect(actual).toContain(pipeline.merge_request.path);
- });
-
- it(`renders the correct source branch link`, () => {
- const actual = vm.$el.querySelector('.js-source-branch-link').href;
-
- expect(actual).toContain(pipeline.merge_request.source_branch_path);
- });
- });
-});
diff --git a/spec/javascripts/jobs/mixins/delayed_job_mixin_spec.js b/spec/javascripts/jobs/mixins/delayed_job_mixin_spec.js
deleted file mode 100644
index b67187f1d50..00000000000
--- a/spec/javascripts/jobs/mixins/delayed_job_mixin_spec.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
-
-describe('DelayedJobMixin', () => {
- const delayedJobFixture = getJSONFixture('jobs/delayed.json');
- const dummyComponent = Vue.extend({
- mixins: [delayedJobMixin],
- props: {
- job: {
- type: Object,
- required: true,
- },
- },
- template: '<div>{{ remainingTime }}</div>',
- });
-
- let vm;
-
- beforeEach(() => {
- jasmine.clock().install();
- });
-
- afterEach(() => {
- vm.$destroy();
- jasmine.clock().uninstall();
- });
-
- describe('if job is empty object', () => {
- beforeEach(() => {
- vm = mountComponent(dummyComponent, {
- job: {},
- });
- });
-
- it('sets remaining time to 00:00:00', () => {
- expect(vm.$el.innerText).toBe('00:00:00');
- });
-
- describe('after mounting', () => {
- beforeEach(done => {
- Vue.nextTick()
- .then(done)
- .catch(done.fail);
- });
-
- it('doe not update remaining time', () => {
- expect(vm.$el.innerText).toBe('00:00:00');
- });
- });
- });
-
- describe('if job is delayed job', () => {
- let remainingTimeInMilliseconds = 42000;
-
- beforeEach(() => {
- spyOn(Date, 'now').and.callFake(
- () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingTimeInMilliseconds,
- );
- vm = mountComponent(dummyComponent, {
- job: delayedJobFixture,
- });
- });
-
- it('sets remaining time to 00:00:00', () => {
- expect(vm.$el.innerText).toBe('00:00:00');
- });
-
- describe('after mounting', () => {
- beforeEach(done => {
- Vue.nextTick()
- .then(done)
- .catch(done.fail);
- });
-
- it('sets remaining time', () => {
- expect(vm.$el.innerText).toBe('00:00:42');
- });
-
- it('updates remaining time', done => {
- remainingTimeInMilliseconds = 41000;
- jasmine.clock().tick(1000);
-
- Vue.nextTick()
- .then(() => {
- expect(vm.$el.innerText).toBe('00:00:41');
- })
- .then(done)
- .catch(done.fail);
- });
- });
- });
-});
diff --git a/spec/javascripts/jobs/store/actions_spec.js b/spec/javascripts/jobs/store/actions_spec.js
deleted file mode 100644
index 47257688bd5..00000000000
--- a/spec/javascripts/jobs/store/actions_spec.js
+++ /dev/null
@@ -1,512 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import testAction from 'spec/helpers/vuex_action_helper';
-import { TEST_HOST } from 'spec/test_constants';
-import axios from '~/lib/utils/axios_utils';
-import {
- setJobEndpoint,
- setTraceOptions,
- clearEtagPoll,
- stopPolling,
- requestJob,
- fetchJob,
- receiveJobSuccess,
- receiveJobError,
- scrollTop,
- scrollBottom,
- requestTrace,
- fetchTrace,
- startPollingTrace,
- stopPollingTrace,
- receiveTraceSuccess,
- receiveTraceError,
- toggleCollapsibleLine,
- requestJobsForStage,
- fetchJobsForStage,
- receiveJobsForStageSuccess,
- receiveJobsForStageError,
- hideSidebar,
- showSidebar,
- toggleSidebar,
-} from '~/jobs/store/actions';
-import state from '~/jobs/store/state';
-import * as types from '~/jobs/store/mutation_types';
-
-describe('Job State actions', () => {
- let mockedState;
-
- beforeEach(() => {
- mockedState = state();
- });
-
- describe('setJobEndpoint', () => {
- it('should commit SET_JOB_ENDPOINT mutation', done => {
- testAction(
- setJobEndpoint,
- 'job/872324.json',
- mockedState,
- [{ type: types.SET_JOB_ENDPOINT, payload: 'job/872324.json' }],
- [],
- done,
- );
- });
- });
-
- describe('setTraceOptions', () => {
- it('should commit SET_TRACE_OPTIONS mutation', done => {
- testAction(
- setTraceOptions,
- { pagePath: 'job/872324/trace.json' },
- mockedState,
- [{ type: types.SET_TRACE_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }],
- [],
- done,
- );
- });
- });
-
- describe('hideSidebar', () => {
- it('should commit HIDE_SIDEBAR mutation', done => {
- testAction(hideSidebar, null, mockedState, [{ type: types.HIDE_SIDEBAR }], [], done);
- });
- });
-
- describe('showSidebar', () => {
- it('should commit HIDE_SIDEBAR mutation', done => {
- testAction(showSidebar, null, mockedState, [{ type: types.SHOW_SIDEBAR }], [], done);
- });
- });
-
- describe('toggleSidebar', () => {
- describe('when isSidebarOpen is true', () => {
- it('should dispatch hideSidebar', done => {
- testAction(toggleSidebar, null, mockedState, [], [{ type: 'hideSidebar' }], done);
- });
- });
-
- describe('when isSidebarOpen is false', () => {
- it('should dispatch showSidebar', done => {
- mockedState.isSidebarOpen = false;
-
- testAction(toggleSidebar, null, mockedState, [], [{ type: 'showSidebar' }], done);
- });
- });
- });
-
- describe('requestJob', () => {
- it('should commit REQUEST_JOB mutation', done => {
- testAction(requestJob, null, mockedState, [{ type: types.REQUEST_JOB }], [], done);
- });
- });
-
- describe('fetchJob', () => {
- let mock;
-
- beforeEach(() => {
- mockedState.jobEndpoint = `${TEST_HOST}/endpoint.json`;
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- stopPolling();
- clearEtagPoll();
- });
-
- describe('success', () => {
- it('dispatches requestJob and receiveJobSuccess ', done => {
- mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 121212, name: 'karma' });
-
- testAction(
- fetchJob,
- null,
- mockedState,
- [],
- [
- {
- type: 'requestJob',
- },
- {
- payload: { id: 121212, name: 'karma' },
- type: 'receiveJobSuccess',
- },
- ],
- done,
- );
- });
- });
-
- describe('error', () => {
- beforeEach(() => {
- mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
- });
-
- it('dispatches requestJob and receiveJobError ', done => {
- testAction(
- fetchJob,
- null,
- mockedState,
- [],
- [
- {
- type: 'requestJob',
- },
- {
- type: 'receiveJobError',
- },
- ],
- done,
- );
- });
- });
- });
-
- describe('receiveJobSuccess', () => {
- it('should commit RECEIVE_JOB_SUCCESS mutation', done => {
- testAction(
- receiveJobSuccess,
- { id: 121232132 },
- mockedState,
- [{ type: types.RECEIVE_JOB_SUCCESS, payload: { id: 121232132 } }],
- [],
- done,
- );
- });
- });
-
- describe('receiveJobError', () => {
- it('should commit RECEIVE_JOB_ERROR mutation', done => {
- testAction(receiveJobError, null, mockedState, [{ type: types.RECEIVE_JOB_ERROR }], [], done);
- });
- });
-
- describe('scrollTop', () => {
- it('should dispatch toggleScrollButtons action', done => {
- testAction(scrollTop, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done);
- });
- });
-
- describe('scrollBottom', () => {
- it('should dispatch toggleScrollButtons action', done => {
- testAction(scrollBottom, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done);
- });
- });
-
- describe('requestTrace', () => {
- it('should commit REQUEST_TRACE mutation', done => {
- testAction(requestTrace, null, mockedState, [{ type: types.REQUEST_TRACE }], [], done);
- });
- });
-
- describe('fetchTrace', () => {
- let mock;
-
- beforeEach(() => {
- mockedState.traceEndpoint = `${TEST_HOST}/endpoint`;
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- stopPolling();
- clearEtagPoll();
- });
-
- describe('success', () => {
- it('dispatches requestTrace, receiveTraceSuccess and stopPollingTrace when job is complete', done => {
- mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, {
- html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
- complete: true,
- });
-
- testAction(
- fetchTrace,
- null,
- mockedState,
- [],
- [
- {
- type: 'toggleScrollisInBottom',
- payload: true,
- },
- {
- payload: {
- html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
- complete: true,
- },
- type: 'receiveTraceSuccess',
- },
- {
- type: 'stopPollingTrace',
- },
- ],
- done,
- );
- });
-
- describe('when job is incomplete', () => {
- let tracePayload;
-
- beforeEach(() => {
- tracePayload = {
- html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
- complete: false,
- };
-
- mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, tracePayload);
- });
-
- it('dispatches startPollingTrace', done => {
- testAction(
- fetchTrace,
- null,
- mockedState,
- [],
- [
- { type: 'toggleScrollisInBottom', payload: true },
- { type: 'receiveTraceSuccess', payload: tracePayload },
- { type: 'startPollingTrace' },
- ],
- done,
- );
- });
-
- it('does not dispatch startPollingTrace when timeout is non-empty', done => {
- mockedState.traceTimeout = 1;
-
- testAction(
- fetchTrace,
- null,
- mockedState,
- [],
- [
- { type: 'toggleScrollisInBottom', payload: true },
- { type: 'receiveTraceSuccess', payload: tracePayload },
- ],
- done,
- );
- });
- });
- });
-
- describe('error', () => {
- beforeEach(() => {
- mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(500);
- });
-
- it('dispatches requestTrace and receiveTraceError ', done => {
- testAction(
- fetchTrace,
- null,
- mockedState,
- [],
- [
- {
- type: 'receiveTraceError',
- },
- ],
- done,
- );
- });
- });
- });
-
- describe('startPollingTrace', () => {
- let dispatch;
- let commit;
-
- beforeEach(() => {
- jasmine.clock().install();
-
- dispatch = jasmine.createSpy();
- commit = jasmine.createSpy();
-
- startPollingTrace({ dispatch, commit });
- });
-
- afterEach(() => {
- jasmine.clock().uninstall();
- });
-
- it('should save the timeout id but not call fetchTrace', () => {
- expect(commit).toHaveBeenCalledWith(types.SET_TRACE_TIMEOUT, 1);
- expect(dispatch).not.toHaveBeenCalledWith('fetchTrace');
- });
-
- describe('after timeout has passed', () => {
- beforeEach(() => {
- jasmine.clock().tick(4000);
- });
-
- it('should clear the timeout id and fetchTrace', () => {
- expect(commit).toHaveBeenCalledWith(types.SET_TRACE_TIMEOUT, 0);
- expect(dispatch).toHaveBeenCalledWith('fetchTrace');
- });
- });
- });
-
- describe('stopPollingTrace', () => {
- let origTimeout;
-
- beforeEach(() => {
- // Can't use spyOn(window, 'clearTimeout') because this caused unrelated specs to timeout
- // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23838#note_280277727
- origTimeout = window.clearTimeout;
- window.clearTimeout = jasmine.createSpy();
- });
-
- afterEach(() => {
- window.clearTimeout = origTimeout;
- });
-
- it('should commit STOP_POLLING_TRACE mutation ', done => {
- const traceTimeout = 7;
-
- testAction(
- stopPollingTrace,
- null,
- { ...mockedState, traceTimeout },
- [{ type: types.SET_TRACE_TIMEOUT, payload: 0 }, { type: types.STOP_POLLING_TRACE }],
- [],
- )
- .then(() => {
- expect(window.clearTimeout).toHaveBeenCalledWith(traceTimeout);
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
- describe('receiveTraceSuccess', () => {
- it('should commit RECEIVE_TRACE_SUCCESS mutation ', done => {
- testAction(
- receiveTraceSuccess,
- 'hello world',
- mockedState,
- [{ type: types.RECEIVE_TRACE_SUCCESS, payload: 'hello world' }],
- [],
- done,
- );
- });
- });
-
- describe('receiveTraceError', () => {
- it('should commit stop polling trace', done => {
- testAction(receiveTraceError, null, mockedState, [], [{ type: 'stopPollingTrace' }], done);
- });
- });
-
- describe('toggleCollapsibleLine', () => {
- it('should commit TOGGLE_COLLAPSIBLE_LINE mutation ', done => {
- testAction(
- toggleCollapsibleLine,
- { isClosed: true },
- mockedState,
- [{ type: types.TOGGLE_COLLAPSIBLE_LINE, payload: { isClosed: true } }],
- [],
- done,
- );
- });
- });
-
- describe('requestJobsForStage', () => {
- it('should commit REQUEST_JOBS_FOR_STAGE mutation ', done => {
- testAction(
- requestJobsForStage,
- { name: 'deploy' },
- mockedState,
- [{ type: types.REQUEST_JOBS_FOR_STAGE, payload: { name: 'deploy' } }],
- [],
- done,
- );
- });
- });
-
- describe('fetchJobsForStage', () => {
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- describe('success', () => {
- it('dispatches requestJobsForStage and receiveJobsForStageSuccess ', done => {
- mock
- .onGet(`${TEST_HOST}/jobs.json`)
- .replyOnce(200, { latest_statuses: [{ id: 121212, name: 'build' }], retried: [] });
-
- testAction(
- fetchJobsForStage,
- { dropdown_path: `${TEST_HOST}/jobs.json` },
- mockedState,
- [],
- [
- {
- type: 'requestJobsForStage',
- payload: { dropdown_path: `${TEST_HOST}/jobs.json` },
- },
- {
- payload: [{ id: 121212, name: 'build' }],
- type: 'receiveJobsForStageSuccess',
- },
- ],
- done,
- );
- });
- });
-
- describe('error', () => {
- beforeEach(() => {
- mock.onGet(`${TEST_HOST}/jobs.json`).reply(500);
- });
-
- it('dispatches requestJobsForStage and receiveJobsForStageError', done => {
- testAction(
- fetchJobsForStage,
- { dropdown_path: `${TEST_HOST}/jobs.json` },
- mockedState,
- [],
- [
- {
- type: 'requestJobsForStage',
- payload: { dropdown_path: `${TEST_HOST}/jobs.json` },
- },
- {
- type: 'receiveJobsForStageError',
- },
- ],
- done,
- );
- });
- });
- });
-
- describe('receiveJobsForStageSuccess', () => {
- it('should commit RECEIVE_JOBS_FOR_STAGE_SUCCESS mutation ', done => {
- testAction(
- receiveJobsForStageSuccess,
- [{ id: 121212, name: 'karma' }],
- mockedState,
- [{ type: types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, payload: [{ id: 121212, name: 'karma' }] }],
- [],
- done,
- );
- });
- });
-
- describe('receiveJobsForStageError', () => {
- it('should commit RECEIVE_JOBS_FOR_STAGE_ERROR mutation ', done => {
- testAction(
- receiveJobsForStageError,
- null,
- mockedState,
- [{ type: types.RECEIVE_JOBS_FOR_STAGE_ERROR }],
- [],
- done,
- );
- });
- });
-});
diff --git a/spec/javascripts/landing_spec.js b/spec/javascripts/landing_spec.js
deleted file mode 100644
index bffef8fc64f..00000000000
--- a/spec/javascripts/landing_spec.js
+++ /dev/null
@@ -1,166 +0,0 @@
-import Cookies from 'js-cookie';
-import Landing from '~/landing';
-
-describe('Landing', function() {
- describe('class constructor', function() {
- beforeEach(function() {
- this.landingElement = {};
- this.dismissButton = {};
- this.cookieName = 'cookie_name';
-
- this.landing = new Landing(this.landingElement, this.dismissButton, this.cookieName);
- });
-
- it('should set .landing', function() {
- expect(this.landing.landingElement).toBe(this.landingElement);
- });
-
- it('should set .cookieName', function() {
- expect(this.landing.cookieName).toBe(this.cookieName);
- });
-
- it('should set .dismissButton', function() {
- expect(this.landing.dismissButton).toBe(this.dismissButton);
- });
-
- it('should set .eventWrapper', function() {
- expect(this.landing.eventWrapper).toEqual({});
- });
- });
-
- describe('toggle', function() {
- beforeEach(function() {
- this.isDismissed = false;
- this.landingElement = { classList: jasmine.createSpyObj('classList', ['toggle']) };
- this.landing = {
- isDismissed: () => {},
- addEvents: () => {},
- landingElement: this.landingElement,
- };
-
- spyOn(this.landing, 'isDismissed').and.returnValue(this.isDismissed);
- spyOn(this.landing, 'addEvents');
-
- Landing.prototype.toggle.call(this.landing);
- });
-
- it('should call .isDismissed', function() {
- expect(this.landing.isDismissed).toHaveBeenCalled();
- });
-
- it('should call .classList.toggle', function() {
- expect(this.landingElement.classList.toggle).toHaveBeenCalledWith('hidden', this.isDismissed);
- });
-
- it('should call .addEvents', function() {
- expect(this.landing.addEvents).toHaveBeenCalled();
- });
-
- describe('if isDismissed is true', function() {
- beforeEach(function() {
- this.isDismissed = true;
- this.landingElement = { classList: jasmine.createSpyObj('classList', ['toggle']) };
- this.landing = {
- isDismissed: () => {},
- addEvents: () => {},
- landingElement: this.landingElement,
- };
-
- spyOn(this.landing, 'isDismissed').and.returnValue(this.isDismissed);
- spyOn(this.landing, 'addEvents');
-
- this.landing.isDismissed.calls.reset();
-
- Landing.prototype.toggle.call(this.landing);
- });
-
- it('should not call .addEvents', function() {
- expect(this.landing.addEvents).not.toHaveBeenCalled();
- });
- });
- });
-
- describe('addEvents', function() {
- beforeEach(function() {
- this.dismissButton = jasmine.createSpyObj('dismissButton', ['addEventListener']);
- this.eventWrapper = {};
- this.landing = {
- eventWrapper: this.eventWrapper,
- dismissButton: this.dismissButton,
- dismissLanding: () => {},
- };
-
- Landing.prototype.addEvents.call(this.landing);
- });
-
- it('should set .eventWrapper.dismissLanding', function() {
- expect(this.eventWrapper.dismissLanding).toEqual(jasmine.any(Function));
- });
-
- it('should call .addEventListener', function() {
- expect(this.dismissButton.addEventListener).toHaveBeenCalledWith(
- 'click',
- this.eventWrapper.dismissLanding,
- );
- });
- });
-
- describe('removeEvents', function() {
- beforeEach(function() {
- this.dismissButton = jasmine.createSpyObj('dismissButton', ['removeEventListener']);
- this.eventWrapper = { dismissLanding: () => {} };
- this.landing = {
- eventWrapper: this.eventWrapper,
- dismissButton: this.dismissButton,
- };
-
- Landing.prototype.removeEvents.call(this.landing);
- });
-
- it('should call .removeEventListener', function() {
- expect(this.dismissButton.removeEventListener).toHaveBeenCalledWith(
- 'click',
- this.eventWrapper.dismissLanding,
- );
- });
- });
-
- describe('dismissLanding', function() {
- beforeEach(function() {
- this.landingElement = { classList: jasmine.createSpyObj('classList', ['add']) };
- this.cookieName = 'cookie_name';
- this.landing = { landingElement: this.landingElement, cookieName: this.cookieName };
-
- spyOn(Cookies, 'set');
-
- Landing.prototype.dismissLanding.call(this.landing);
- });
-
- it('should call .classList.add', function() {
- expect(this.landingElement.classList.add).toHaveBeenCalledWith('hidden');
- });
-
- it('should call Cookies.set', function() {
- expect(Cookies.set).toHaveBeenCalledWith(this.cookieName, 'true', { expires: 365 });
- });
- });
-
- describe('isDismissed', function() {
- beforeEach(function() {
- this.cookieName = 'cookie_name';
- this.landing = { cookieName: this.cookieName };
-
- spyOn(Cookies, 'get').and.returnValue('true');
-
- this.isDismissed = Landing.prototype.isDismissed.call(this.landing);
- });
-
- it('should call Cookies.get', function() {
- expect(Cookies.get).toHaveBeenCalledWith(this.cookieName);
- });
-
- it('should return a boolean', function() {
- expect(typeof this.isDismissed).toEqual('boolean');
- });
- });
-});
diff --git a/spec/javascripts/lib/utils/csrf_token_spec.js b/spec/javascripts/lib/utils/csrf_token_spec.js
deleted file mode 100644
index 867bee34ee5..00000000000
--- a/spec/javascripts/lib/utils/csrf_token_spec.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import csrf from '~/lib/utils/csrf';
-
-describe('csrf', function() {
- beforeEach(() => {
- this.tokenKey = 'X-CSRF-Token';
- this.token =
- 'pH1cvjnP9grx2oKlhWEDvUZnJ8x2eXsIs1qzyHkF3DugSG5yTxR76CWeEZRhML2D1IeVB7NEW0t5l/axE4iJpQ==';
- });
-
- it('returns the correct headerKey', () => {
- expect(csrf.headerKey).toBe(this.tokenKey);
- });
-
- describe('when csrf token is in the DOM', () => {
- beforeEach(() => {
- setFixtures(`
- <meta name="csrf-token" content="${this.token}">
- `);
-
- csrf.init();
- });
-
- it('returns the csrf token', () => {
- expect(csrf.token).toBe(this.token);
- });
-
- it('returns the csrf headers object', () => {
- expect(csrf.headers[this.tokenKey]).toBe(this.token);
- });
- });
-
- describe('when csrf token is not in the DOM', () => {
- beforeEach(() => {
- setFixtures(`
- <meta name="some-other-token">
- `);
-
- csrf.init();
- });
-
- it('returns null for token', () => {
- expect(csrf.token).toBeNull();
- });
-
- it('returns empty object for headers', () => {
- expect(typeof csrf.headers).toBe('object');
- expect(Object.keys(csrf.headers).length).toBe(0);
- });
- });
-});
diff --git a/spec/javascripts/lib/utils/navigation_utility_spec.js b/spec/javascripts/lib/utils/navigation_utility_spec.js
deleted file mode 100644
index be620e4a27c..00000000000
--- a/spec/javascripts/lib/utils/navigation_utility_spec.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import findAndFollowLink from '~/lib/utils/navigation_utility';
-
-describe('findAndFollowLink', () => {
- it('visits a link when the selector exists', () => {
- const href = '/some/path';
- const visitUrl = spyOnDependency(findAndFollowLink, 'visitUrl');
-
- setFixtures(`<a class="my-shortcut" href="${href}">link</a>`);
-
- findAndFollowLink('.my-shortcut');
-
- expect(visitUrl).toHaveBeenCalledWith(href);
- });
-
- it('does not throw an exception when the selector does not exist', () => {
- const visitUrl = spyOnDependency(findAndFollowLink, 'visitUrl');
-
- // this should not throw an exception
- findAndFollowLink('.this-selector-does-not-exist');
-
- expect(visitUrl).not.toHaveBeenCalled();
- });
-});
diff --git a/spec/javascripts/lib/utils/poll_spec.js b/spec/javascripts/lib/utils/poll_spec.js
deleted file mode 100644
index 138041a349f..00000000000
--- a/spec/javascripts/lib/utils/poll_spec.js
+++ /dev/null
@@ -1,222 +0,0 @@
-/* eslint-disable jasmine/no-unsafe-spy */
-
-import Poll from '~/lib/utils/poll';
-import { successCodes } from '~/lib/utils/http_status';
-
-const waitForAllCallsToFinish = (service, waitForCount, successCallback) => {
- const timer = () => {
- setTimeout(() => {
- if (service.fetch.calls.count() === waitForCount) {
- successCallback();
- } else {
- timer();
- }
- }, 0);
- };
-
- timer();
-};
-
-function mockServiceCall(service, response, shouldFail = false) {
- const action = shouldFail ? Promise.reject : Promise.resolve;
- const responseObject = response;
-
- if (!responseObject.headers) responseObject.headers = {};
-
- service.fetch.and.callFake(action.bind(Promise, responseObject));
-}
-
-describe('Poll', () => {
- const service = jasmine.createSpyObj('service', ['fetch']);
- const callbacks = jasmine.createSpyObj('callbacks', ['success', 'error', 'notification']);
-
- function setup() {
- return new Poll({
- resource: service,
- method: 'fetch',
- successCallback: callbacks.success,
- errorCallback: callbacks.error,
- notificationCallback: callbacks.notification,
- }).makeRequest();
- }
-
- afterEach(() => {
- callbacks.success.calls.reset();
- callbacks.error.calls.reset();
- callbacks.notification.calls.reset();
- service.fetch.calls.reset();
- });
-
- it('calls the success callback when no header for interval is provided', done => {
- mockServiceCall(service, { status: 200 });
- setup();
-
- waitForAllCallsToFinish(service, 1, () => {
- expect(callbacks.success).toHaveBeenCalled();
- expect(callbacks.error).not.toHaveBeenCalled();
-
- done();
- });
- });
-
- it('calls the error callback when the http request returns an error', done => {
- mockServiceCall(service, { status: 500 }, true);
- setup();
-
- waitForAllCallsToFinish(service, 1, () => {
- expect(callbacks.success).not.toHaveBeenCalled();
- expect(callbacks.error).toHaveBeenCalled();
-
- done();
- });
- });
-
- it('skips the error callback when request is aborted', done => {
- mockServiceCall(service, { status: 0 }, true);
- setup();
-
- waitForAllCallsToFinish(service, 1, () => {
- expect(callbacks.success).not.toHaveBeenCalled();
- expect(callbacks.error).not.toHaveBeenCalled();
- expect(callbacks.notification).toHaveBeenCalled();
-
- done();
- });
- });
-
- it('should call the success callback when the interval header is -1', done => {
- mockServiceCall(service, { status: 200, headers: { 'poll-interval': -1 } });
- setup()
- .then(() => {
- expect(callbacks.success).toHaveBeenCalled();
- expect(callbacks.error).not.toHaveBeenCalled();
-
- done();
- })
- .catch(done.fail);
- });
-
- describe('for 2xx status code', () => {
- successCodes.forEach(httpCode => {
- it(`starts polling when http status is ${httpCode} and interval header is provided`, done => {
- mockServiceCall(service, { status: httpCode, headers: { 'poll-interval': 1 } });
-
- const Polling = new Poll({
- resource: service,
- method: 'fetch',
- data: { page: 1 },
- successCallback: callbacks.success,
- errorCallback: callbacks.error,
- });
-
- Polling.makeRequest();
-
- waitForAllCallsToFinish(service, 2, () => {
- Polling.stop();
-
- expect(service.fetch.calls.count()).toEqual(2);
- expect(service.fetch).toHaveBeenCalledWith({ page: 1 });
- expect(callbacks.success).toHaveBeenCalled();
- expect(callbacks.error).not.toHaveBeenCalled();
-
- done();
- });
- });
- });
- });
-
- describe('stop', () => {
- it('stops polling when method is called', done => {
- mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } });
-
- const Polling = new Poll({
- resource: service,
- method: 'fetch',
- data: { page: 1 },
- successCallback: () => {
- Polling.stop();
- },
- errorCallback: callbacks.error,
- });
-
- spyOn(Polling, 'stop').and.callThrough();
-
- Polling.makeRequest();
-
- waitForAllCallsToFinish(service, 1, () => {
- expect(service.fetch.calls.count()).toEqual(1);
- expect(service.fetch).toHaveBeenCalledWith({ page: 1 });
- expect(Polling.stop).toHaveBeenCalled();
-
- done();
- });
- });
- });
-
- describe('enable', () => {
- it('should enable polling upon a response', done => {
- jasmine.clock().install();
-
- const Polling = new Poll({
- resource: service,
- method: 'fetch',
- data: { page: 1 },
- successCallback: () => {},
- });
-
- Polling.enable({
- data: { page: 4 },
- response: { status: 200, headers: { 'poll-interval': 1 } },
- });
-
- jasmine.clock().tick(1);
- jasmine.clock().uninstall();
-
- waitForAllCallsToFinish(service, 1, () => {
- Polling.stop();
-
- expect(service.fetch.calls.count()).toEqual(1);
- expect(service.fetch).toHaveBeenCalledWith({ page: 4 });
- expect(Polling.options.data).toEqual({ page: 4 });
- done();
- });
- });
- });
-
- describe('restart', () => {
- it('should restart polling when its called', done => {
- mockServiceCall(service, { status: 200, headers: { 'poll-interval': 1 } });
-
- const Polling = new Poll({
- resource: service,
- method: 'fetch',
- data: { page: 1 },
- successCallback: () => {
- Polling.stop();
- setTimeout(() => {
- Polling.restart({ data: { page: 4 } });
- }, 0);
- },
- errorCallback: callbacks.error,
- });
-
- spyOn(Polling, 'stop').and.callThrough();
- spyOn(Polling, 'enable').and.callThrough();
- spyOn(Polling, 'restart').and.callThrough();
-
- Polling.makeRequest();
-
- waitForAllCallsToFinish(service, 2, () => {
- Polling.stop();
-
- expect(service.fetch.calls.count()).toEqual(2);
- expect(service.fetch).toHaveBeenCalledWith({ page: 4 });
- expect(Polling.stop).toHaveBeenCalled();
- expect(Polling.enable).toHaveBeenCalled();
- expect(Polling.restart).toHaveBeenCalled();
- expect(Polling.options.data).toEqual({ page: 4 });
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/lib/utils/sticky_spec.js b/spec/javascripts/lib/utils/sticky_spec.js
deleted file mode 100644
index 1b1e7da1ed3..00000000000
--- a/spec/javascripts/lib/utils/sticky_spec.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import { isSticky } from '~/lib/utils/sticky';
-
-describe('sticky', () => {
- let el;
-
- beforeEach(() => {
- document.body.innerHTML += `
- <div class="parent">
- <div id="js-sticky"></div>
- </div>
- `;
-
- el = document.getElementById('js-sticky');
- });
-
- afterEach(() => {
- el.parentNode.remove();
- });
-
- describe('when stuck', () => {
- it('does not remove is-stuck class', () => {
- isSticky(el, 0, el.offsetTop);
- isSticky(el, 0, el.offsetTop);
-
- expect(el.classList.contains('is-stuck')).toBeTruthy();
- });
-
- it('adds is-stuck class', () => {
- isSticky(el, 0, el.offsetTop);
-
- expect(el.classList.contains('is-stuck')).toBeTruthy();
- });
-
- it('inserts placeholder element', () => {
- isSticky(el, 0, el.offsetTop, true);
-
- expect(document.querySelector('.sticky-placeholder')).not.toBeNull();
- });
- });
-
- describe('when not stuck', () => {
- it('removes is-stuck class', () => {
- spyOn(el.classList, 'remove').and.callThrough();
-
- isSticky(el, 0, el.offsetTop);
- isSticky(el, 0, 0);
-
- expect(el.classList.remove).toHaveBeenCalledWith('is-stuck');
-
- expect(el.classList.contains('is-stuck')).toBeFalsy();
- });
-
- it('does not add is-stuck class', () => {
- isSticky(el, 0, 0);
-
- expect(el.classList.contains('is-stuck')).toBeFalsy();
- });
-
- it('removes placeholder', () => {
- isSticky(el, 0, el.offsetTop, true);
- isSticky(el, 0, 0, true);
-
- expect(document.querySelector('.sticky-placeholder')).toBeNull();
- });
- });
-});
diff --git a/spec/javascripts/line_highlighter_spec.js b/spec/javascripts/line_highlighter_spec.js
index 45b10fc3bd8..bedab0fd003 100644
--- a/spec/javascripts/line_highlighter_spec.js
+++ b/spec/javascripts/line_highlighter_spec.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-else-return, dot-notation, no-return-assign, no-new, no-underscore-dangle */
+/* eslint-disable dot-notation, no-return-assign, no-new, no-underscore-dangle */
import $ from 'jquery';
import LineHighlighter from '~/line_highlighter';
@@ -8,10 +8,9 @@ describe('LineHighlighter', function() {
const clickLine = function(number, eventData = {}) {
if ($.isEmptyObject(eventData)) {
return $(`#L${number}`).click();
- } else {
- const e = $.Event('click', eventData);
- return $(`#L${number}`).trigger(e);
}
+ const e = $.Event('click', eventData);
+ return $(`#L${number}`).trigger(e);
};
beforeEach(function() {
loadFixtures('static/line_highlighter.html');
@@ -67,6 +66,16 @@ describe('LineHighlighter', function() {
expect(func).not.toThrow();
});
+
+ it('handles hashchange event', () => {
+ const highlighter = new LineHighlighter();
+
+ spyOn(highlighter, 'highlightHash');
+
+ window.dispatchEvent(new Event('hashchange'), 'L15');
+
+ expect(highlighter.highlightHash).toHaveBeenCalled();
+ });
});
describe('clickHandler', function() {
diff --git a/spec/javascripts/monitoring/components/dashboard_resize_browser_spec.js b/spec/javascripts/monitoring/components/dashboard_resize_browser_spec.js
new file mode 100644
index 00000000000..4416dbd014a
--- /dev/null
+++ b/spec/javascripts/monitoring/components/dashboard_resize_browser_spec.js
@@ -0,0 +1,94 @@
+/**
+ * This file should only contain browser specific specs.
+ * If you need to add or update a spec, please see spec/frontend/monitoring/components/*.js
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/194244#note_343427737
+ * https://gitlab.com/groups/gitlab-org/-/epics/895#what-if-theres-a-karma-spec-which-is-simply-unmovable-to-jest-ie-it-is-dependent-on-a-running-browser-environment
+ */
+
+import Vue from 'vue';
+import { createLocalVue } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import Dashboard from '~/monitoring/components/dashboard.vue';
+import { createStore } from '~/monitoring/stores';
+import axios from '~/lib/utils/axios_utils';
+import { mockApiEndpoint, propsData } from '../mock_data';
+import { metricsDashboardPayload } from '../fixture_data';
+import { setupStoreWithData } from '../store_utils';
+
+const localVue = createLocalVue();
+
+describe('Dashboard', () => {
+ let DashboardComponent;
+ let mock;
+ let store;
+ let component;
+ let wrapper;
+
+ beforeEach(() => {
+ setFixtures(`
+ <div class="prometheus-graphs"></div>
+ <div class="layout-page"></div>
+ `);
+
+ store = createStore();
+ mock = new MockAdapter(axios);
+ DashboardComponent = localVue.extend(Dashboard);
+ });
+
+ afterEach(() => {
+ if (component) {
+ component.$destroy();
+ }
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ mock.restore();
+ });
+
+ describe('responds to window resizes', () => {
+ let promPanel;
+ let promGroup;
+ let panelToggle;
+ let chart;
+ beforeEach(() => {
+ mock.onGet(mockApiEndpoint).reply(200, metricsDashboardPayload);
+
+ component = new DashboardComponent({
+ el: document.querySelector('.prometheus-graphs'),
+ propsData: {
+ ...propsData,
+ hasMetrics: true,
+ showPanels: true,
+ },
+ store,
+ });
+
+ setupStoreWithData(component.$store);
+
+ return Vue.nextTick().then(() => {
+ [promPanel] = component.$el.querySelectorAll('.prometheus-panel');
+ promGroup = promPanel.querySelector('.prometheus-graph-group');
+ panelToggle = promPanel.querySelector('.js-graph-group-toggle');
+ chart = promGroup.querySelector('.position-relative svg');
+ });
+ });
+
+ it('setting chart size to zero when panel group is hidden', () => {
+ expect(promGroup.style.display).toBe('');
+ expect(chart.clientWidth).toBeGreaterThan(0);
+
+ panelToggle.click();
+ return Vue.nextTick().then(() => {
+ expect(promGroup.style.display).toBe('none');
+ expect(chart.clientWidth).toBe(0);
+ promPanel.style.width = '500px';
+ });
+ });
+
+ it('expanding chart panel group after resize displays chart', () => {
+ panelToggle.click();
+
+ expect(chart.clientWidth).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/spec/javascripts/monitoring/components/dashboard_resize_spec.js b/spec/javascripts/monitoring/components/dashboard_resize_spec.js
deleted file mode 100644
index 0c3193940e6..00000000000
--- a/spec/javascripts/monitoring/components/dashboard_resize_spec.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import Vue from 'vue';
-import { createLocalVue } from '@vue/test-utils';
-import MockAdapter from 'axios-mock-adapter';
-import Dashboard from '~/monitoring/components/dashboard.vue';
-import { createStore } from '~/monitoring/stores';
-import axios from '~/lib/utils/axios_utils';
-import { mockApiEndpoint, propsData } from '../mock_data';
-import { metricsDashboardPayload } from '../fixture_data';
-import { setupStoreWithData } from '../store_utils';
-
-const localVue = createLocalVue();
-
-describe('Dashboard', () => {
- let DashboardComponent;
- let mock;
- let store;
- let component;
- let wrapper;
-
- beforeEach(() => {
- setFixtures(`
- <div class="prometheus-graphs"></div>
- <div class="layout-page"></div>
- `);
-
- store = createStore();
- mock = new MockAdapter(axios);
- DashboardComponent = localVue.extend(Dashboard);
- });
-
- afterEach(() => {
- if (component) {
- component.$destroy();
- }
- if (wrapper) {
- wrapper.destroy();
- }
- mock.restore();
- });
-
- describe('responds to window resizes', () => {
- let promPanel;
- let promGroup;
- let panelToggle;
- let chart;
- beforeEach(() => {
- mock.onGet(mockApiEndpoint).reply(200, metricsDashboardPayload);
-
- component = new DashboardComponent({
- el: document.querySelector('.prometheus-graphs'),
- propsData: {
- ...propsData,
- hasMetrics: true,
- showPanels: true,
- },
- store,
- });
-
- setupStoreWithData(component.$store);
-
- return Vue.nextTick().then(() => {
- [promPanel] = component.$el.querySelectorAll('.prometheus-panel');
- promGroup = promPanel.querySelector('.prometheus-graph-group');
- panelToggle = promPanel.querySelector('.js-graph-group-toggle');
- chart = promGroup.querySelector('.position-relative svg');
- });
- });
-
- it('setting chart size to zero when panel group is hidden', () => {
- expect(promGroup.style.display).toBe('');
- expect(chart.clientWidth).toBeGreaterThan(0);
-
- panelToggle.click();
- return Vue.nextTick().then(() => {
- expect(promGroup.style.display).toBe('none');
- expect(chart.clientWidth).toBe(0);
- promPanel.style.width = '500px';
- });
- });
-
- it('expanding chart panel group after resize displays chart', () => {
- panelToggle.click();
-
- expect(chart.clientWidth).toBeGreaterThan(0);
- });
- });
-});
diff --git a/spec/javascripts/notebook/cells/code_spec.js b/spec/javascripts/notebook/cells/code_spec.js
deleted file mode 100644
index f3f97145ad3..00000000000
--- a/spec/javascripts/notebook/cells/code_spec.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import Vue from 'vue';
-import CodeComponent from '~/notebook/cells/code.vue';
-
-const Component = Vue.extend(CodeComponent);
-
-describe('Code component', () => {
- let vm;
- let json;
-
- beforeEach(() => {
- json = getJSONFixture('blob/notebook/basic.json');
- });
-
- const setupComponent = cell => {
- const comp = new Component({
- propsData: {
- cell,
- },
- });
- comp.$mount();
- return comp;
- };
-
- describe('without output', () => {
- beforeEach(done => {
- vm = setupComponent(json.cells[0]);
-
- setTimeout(() => {
- done();
- });
- });
-
- it('does not render output prompt', () => {
- expect(vm.$el.querySelectorAll('.prompt').length).toBe(1);
- });
- });
-
- describe('with output', () => {
- beforeEach(done => {
- vm = setupComponent(json.cells[2]);
-
- setTimeout(() => {
- done();
- });
- });
-
- it('does not render output prompt', () => {
- expect(vm.$el.querySelectorAll('.prompt').length).toBe(2);
- });
-
- it('renders output cell', () => {
- expect(vm.$el.querySelector('.output')).toBeDefined();
- });
- });
-
- describe('with string for cell.source', () => {
- beforeEach(done => {
- const cell = json.cells[0];
- cell.source = cell.source.join('');
-
- vm = setupComponent(cell);
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders the same input as when cell.source is an array', () => {
- const expected = "console.log('test')";
-
- expect(vm.$el.querySelector('.input').innerText).toContain(expected);
- });
- });
-});
diff --git a/spec/javascripts/notebook/cells/markdown_spec.js b/spec/javascripts/notebook/cells/markdown_spec.js
deleted file mode 100644
index 07b18d97cd9..00000000000
--- a/spec/javascripts/notebook/cells/markdown_spec.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import Vue from 'vue';
-import katex from 'katex';
-import MarkdownComponent from '~/notebook/cells/markdown.vue';
-
-const Component = Vue.extend(MarkdownComponent);
-
-window.katex = katex;
-
-describe('Markdown component', () => {
- let vm;
- let cell;
- let json;
-
- beforeEach(done => {
- json = getJSONFixture('blob/notebook/basic.json');
-
- // eslint-disable-next-line prefer-destructuring
- cell = json.cells[1];
-
- vm = new Component({
- propsData: {
- cell,
- },
- });
- vm.$mount();
-
- setTimeout(() => {
- done();
- });
- });
-
- it('does not render promot', () => {
- expect(vm.$el.querySelector('.prompt span')).toBeNull();
- });
-
- it('does not render the markdown text', () => {
- expect(vm.$el.querySelector('.markdown').innerHTML.trim()).not.toEqual(cell.source.join(''));
- });
-
- it('renders the markdown HTML', () => {
- expect(vm.$el.querySelector('.markdown h1')).not.toBeNull();
- });
-
- it('sanitizes output', done => {
- Object.assign(cell, {
- source: [
- '[XSS](data:text/html;base64,PHNjcmlwdD5hbGVydChkb2N1bWVudC5kb21haW4pPC9zY3JpcHQ+Cg==)\n',
- ],
- });
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('a').getAttribute('href')).toBeNull();
-
- done();
- });
- });
-
- describe('katex', () => {
- beforeEach(() => {
- json = getJSONFixture('blob/notebook/math.json');
- });
-
- it('renders multi-line katex', done => {
- vm = new Component({
- propsData: {
- cell: json.cells[0],
- },
- }).$mount();
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.katex')).not.toBeNull();
-
- done();
- });
- });
-
- it('renders inline katex', done => {
- vm = new Component({
- propsData: {
- cell: json.cells[1],
- },
- }).$mount();
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('p:first-child .katex')).not.toBeNull();
-
- done();
- });
- });
-
- it('renders multiple inline katex', done => {
- vm = new Component({
- propsData: {
- cell: json.cells[1],
- },
- }).$mount();
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelectorAll('p:nth-child(2) .katex').length).toBe(4);
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/notebook/cells/output/index_spec.js b/spec/javascripts/notebook/cells/output/index_spec.js
deleted file mode 100644
index 005569f1c2d..00000000000
--- a/spec/javascripts/notebook/cells/output/index_spec.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import Vue from 'vue';
-import CodeComponent from '~/notebook/cells/output/index.vue';
-
-const Component = Vue.extend(CodeComponent);
-
-describe('Output component', () => {
- let vm;
- let json;
-
- const createComponent = output => {
- vm = new Component({
- propsData: {
- outputs: [].concat(output),
- count: 1,
- },
- });
- vm.$mount();
- };
-
- beforeEach(() => {
- json = getJSONFixture('blob/notebook/basic.json');
- });
-
- describe('text output', () => {
- beforeEach(done => {
- createComponent(json.cells[2].outputs[0]);
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders as plain text', () => {
- expect(vm.$el.querySelector('pre')).not.toBeNull();
- });
-
- it('renders promot', () => {
- expect(vm.$el.querySelector('.prompt span')).not.toBeNull();
- });
- });
-
- describe('image output', () => {
- beforeEach(done => {
- createComponent(json.cells[3].outputs[0]);
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders as an image', () => {
- expect(vm.$el.querySelector('img')).not.toBeNull();
- });
- });
-
- describe('html output', () => {
- it('renders raw HTML', () => {
- createComponent(json.cells[4].outputs[0]);
-
- expect(vm.$el.querySelector('p')).not.toBeNull();
- expect(vm.$el.querySelectorAll('p').length).toBe(1);
- expect(vm.$el.textContent.trim()).toContain('test');
- });
-
- it('renders multiple raw HTML outputs', () => {
- createComponent([json.cells[4].outputs[0], json.cells[4].outputs[0]]);
-
- expect(vm.$el.querySelectorAll('p').length).toBe(2);
- });
- });
-
- describe('svg output', () => {
- beforeEach(done => {
- createComponent(json.cells[5].outputs[0]);
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders as an svg', () => {
- expect(vm.$el.querySelector('svg')).not.toBeNull();
- });
- });
-
- describe('default to plain text', () => {
- beforeEach(done => {
- createComponent(json.cells[6].outputs[0]);
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders as plain text', () => {
- expect(vm.$el.querySelector('pre')).not.toBeNull();
- expect(vm.$el.textContent.trim()).toContain('testing');
- });
-
- it('renders promot', () => {
- expect(vm.$el.querySelector('.prompt span')).not.toBeNull();
- });
-
- it("renders as plain text when doesn't recognise other types", done => {
- createComponent(json.cells[7].outputs[0]);
-
- setTimeout(() => {
- expect(vm.$el.querySelector('pre')).not.toBeNull();
- expect(vm.$el.textContent.trim()).toContain('testing');
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/notebook/cells/prompt_spec.js b/spec/javascripts/notebook/cells/prompt_spec.js
deleted file mode 100644
index cbbcb1e68e3..00000000000
--- a/spec/javascripts/notebook/cells/prompt_spec.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import Vue from 'vue';
-import PromptComponent from '~/notebook/cells/prompt.vue';
-
-const Component = Vue.extend(PromptComponent);
-
-describe('Prompt component', () => {
- let vm;
-
- describe('input', () => {
- beforeEach(done => {
- vm = new Component({
- propsData: {
- type: 'In',
- count: 1,
- },
- });
- vm.$mount();
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders in label', () => {
- expect(vm.$el.textContent.trim()).toContain('In');
- });
-
- it('renders count', () => {
- expect(vm.$el.textContent.trim()).toContain('1');
- });
- });
-
- describe('output', () => {
- beforeEach(done => {
- vm = new Component({
- propsData: {
- type: 'Out',
- count: 1,
- },
- });
- vm.$mount();
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders in label', () => {
- expect(vm.$el.textContent.trim()).toContain('Out');
- });
-
- it('renders count', () => {
- expect(vm.$el.textContent.trim()).toContain('1');
- });
- });
-});
diff --git a/spec/javascripts/notebook/index_spec.js b/spec/javascripts/notebook/index_spec.js
deleted file mode 100644
index 2e2ea5ad8af..00000000000
--- a/spec/javascripts/notebook/index_spec.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import Vue from 'vue';
-import Notebook from '~/notebook/index.vue';
-
-const Component = Vue.extend(Notebook);
-
-describe('Notebook component', () => {
- let vm;
- let json;
- let jsonWithWorksheet;
-
- beforeEach(() => {
- json = getJSONFixture('blob/notebook/basic.json');
- jsonWithWorksheet = getJSONFixture('blob/notebook/worksheets.json');
- });
-
- describe('without JSON', () => {
- beforeEach(done => {
- vm = new Component({
- propsData: {
- notebook: {},
- },
- });
- vm.$mount();
-
- setTimeout(() => {
- done();
- });
- });
-
- it('does not render', () => {
- expect(vm.$el.tagName).toBeUndefined();
- });
- });
-
- describe('with JSON', () => {
- beforeEach(done => {
- vm = new Component({
- propsData: {
- notebook: json,
- codeCssClass: 'js-code-class',
- },
- });
- vm.$mount();
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders cells', () => {
- expect(vm.$el.querySelectorAll('.cell').length).toBe(json.cells.length);
- });
-
- it('renders markdown cell', () => {
- expect(vm.$el.querySelector('.markdown')).not.toBeNull();
- });
-
- it('renders code cell', () => {
- expect(vm.$el.querySelector('pre')).not.toBeNull();
- });
-
- it('add code class to code blocks', () => {
- expect(vm.$el.querySelector('.js-code-class')).not.toBeNull();
- });
- });
-
- describe('with worksheets', () => {
- beforeEach(done => {
- vm = new Component({
- propsData: {
- notebook: jsonWithWorksheet,
- codeCssClass: 'js-code-class',
- },
- });
- vm.$mount();
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders cells', () => {
- expect(vm.$el.querySelectorAll('.cell').length).toBe(
- jsonWithWorksheet.worksheets[0].cells.length,
- );
- });
-
- it('renders markdown cell', () => {
- expect(vm.$el.querySelector('.markdown')).not.toBeNull();
- });
-
- it('renders code cell', () => {
- expect(vm.$el.querySelector('pre')).not.toBeNull();
- });
-
- it('add code class to code blocks', () => {
- expect(vm.$el.querySelector('.js-code-class')).not.toBeNull();
- });
- });
-});
diff --git a/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js b/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
deleted file mode 100644
index 9ad72e0b043..00000000000
--- a/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import Vue from 'vue';
-
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import axios from '~/lib/utils/axios_utils';
-import stopJobsModal from '~/pages/admin/jobs/index/components/stop_jobs_modal.vue';
-
-describe('stop_jobs_modal.vue', () => {
- const props = {
- url: `${gl.TEST_HOST}/stop_jobs_modal.vue/stopAll`,
- };
- let vm;
-
- afterEach(() => {
- vm.$destroy();
- });
-
- beforeEach(() => {
- const Component = Vue.extend(stopJobsModal);
- vm = mountComponent(Component, props);
- });
-
- describe('onSubmit', () => {
- it('stops jobs and redirects to overview page', done => {
- const responseURL = `${gl.TEST_HOST}/stop_jobs_modal.vue/jobs`;
- const redirectSpy = spyOnDependency(stopJobsModal, 'redirectTo');
- spyOn(axios, 'post').and.callFake(url => {
- expect(url).toBe(props.url);
- return Promise.resolve({
- request: {
- responseURL,
- },
- });
- });
-
- vm.onSubmit()
- .then(() => {
- expect(redirectSpy).toHaveBeenCalledWith(responseURL);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('displays error if stopping jobs failed', done => {
- const dummyError = new Error('stopping jobs failed');
- const redirectSpy = spyOnDependency(stopJobsModal, 'redirectTo');
- spyOn(axios, 'post').and.callFake(url => {
- expect(url).toBe(props.url);
- return Promise.reject(dummyError);
- });
-
- vm.onSubmit()
- .then(done.fail)
- .catch(error => {
- expect(error).toBe(dummyError);
- expect(redirectSpy).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js
deleted file mode 100644
index 5bad13c1ef2..00000000000
--- a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue';
-import eventHub from '~/pages/projects/labels/event_hub';
-import axios from '~/lib/utils/axios_utils';
-
-describe('Promote label modal', () => {
- let vm;
- const Component = Vue.extend(promoteLabelModal);
- const labelMockData = {
- labelTitle: 'Documentation',
- labelColor: '#5cb85c',
- labelTextColor: '#ffffff',
- url: `${gl.TEST_HOST}/dummy/promote/labels`,
- groupName: 'group',
- };
-
- describe('Modal title and description', () => {
- beforeEach(() => {
- vm = mountComponent(Component, labelMockData);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('contains the proper description', () => {
- expect(vm.text).toContain(
- `Promoting ${labelMockData.labelTitle} will make it available for all projects inside ${labelMockData.groupName}`,
- );
- });
-
- it('contains a label span with the color', () => {
- const labelFromTitle = vm.$el.querySelector('.modal-header .label.color-label');
-
- expect(labelFromTitle.style.backgroundColor).not.toBe(null);
- expect(labelFromTitle.textContent).toContain(vm.labelTitle);
- });
- });
-
- describe('When requesting a label promotion', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- ...labelMockData,
- });
- spyOn(eventHub, '$emit');
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('redirects when a label is promoted', done => {
- const responseURL = `${gl.TEST_HOST}/dummy/endpoint`;
- spyOn(axios, 'post').and.callFake(url => {
- expect(url).toBe(labelMockData.url);
- expect(eventHub.$emit).toHaveBeenCalledWith(
- 'promoteLabelModal.requestStarted',
- labelMockData.url,
- );
- return Promise.resolve({
- request: {
- responseURL,
- },
- });
- });
-
- vm.onSubmit()
- .then(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
- labelUrl: labelMockData.url,
- successful: true,
- });
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('displays an error if promoting a label failed', done => {
- const dummyError = new Error('promoting label failed');
- dummyError.response = { status: 500 };
- spyOn(axios, 'post').and.callFake(url => {
- expect(url).toBe(labelMockData.url);
- expect(eventHub.$emit).toHaveBeenCalledWith(
- 'promoteLabelModal.requestStarted',
- labelMockData.url,
- );
- return Promise.reject(dummyError);
- });
-
- vm.onSubmit()
- .catch(error => {
- expect(error).toBe(dummyError);
- expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
- labelUrl: labelMockData.url,
- successful: false,
- });
- })
- .then(done)
- .catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js
deleted file mode 100644
index 9075c8aa97a..00000000000
--- a/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import Vue from 'vue';
-
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import axios from '~/lib/utils/axios_utils';
-import deleteMilestoneModal from '~/pages/milestones/shared/components/delete_milestone_modal.vue';
-import eventHub from '~/pages/milestones/shared/event_hub';
-
-describe('delete_milestone_modal.vue', () => {
- const Component = Vue.extend(deleteMilestoneModal);
- const props = {
- issueCount: 1,
- mergeRequestCount: 2,
- milestoneId: 3,
- milestoneTitle: 'my milestone title',
- milestoneUrl: `${gl.TEST_HOST}/delete_milestone_modal.vue/milestone`,
- };
- let vm;
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('onSubmit', () => {
- beforeEach(() => {
- vm = mountComponent(Component, props);
- spyOn(eventHub, '$emit');
- });
-
- it('deletes milestone and redirects to overview page', done => {
- const responseURL = `${gl.TEST_HOST}/delete_milestone_modal.vue/milestoneOverview`;
- spyOn(axios, 'delete').and.callFake(url => {
- expect(url).toBe(props.milestoneUrl);
- expect(eventHub.$emit).toHaveBeenCalledWith(
- 'deleteMilestoneModal.requestStarted',
- props.milestoneUrl,
- );
- eventHub.$emit.calls.reset();
- return Promise.resolve({
- request: {
- responseURL,
- },
- });
- });
- const redirectSpy = spyOnDependency(deleteMilestoneModal, 'redirectTo');
-
- vm.onSubmit()
- .then(() => {
- expect(redirectSpy).toHaveBeenCalledWith(responseURL);
- expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
- milestoneUrl: props.milestoneUrl,
- successful: true,
- });
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('displays error if deleting milestone failed', done => {
- const dummyError = new Error('deleting milestone failed');
- dummyError.response = { status: 418 };
- spyOn(axios, 'delete').and.callFake(url => {
- expect(url).toBe(props.milestoneUrl);
- expect(eventHub.$emit).toHaveBeenCalledWith(
- 'deleteMilestoneModal.requestStarted',
- props.milestoneUrl,
- );
- eventHub.$emit.calls.reset();
- return Promise.reject(dummyError);
- });
- const redirectSpy = spyOnDependency(deleteMilestoneModal, 'redirectTo');
-
- vm.onSubmit()
- .catch(error => {
- expect(error).toBe(dummyError);
- expect(redirectSpy).not.toHaveBeenCalled();
- expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
- milestoneUrl: props.milestoneUrl,
- successful: false,
- });
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
- describe('text', () => {
- it('contains the issue and milestone count', () => {
- vm = mountComponent(Component, props);
- const value = vm.text;
-
- expect(value).toContain('remove it from 1 issue and 2 merge requests');
- });
-
- it('contains neither issue nor milestone count', () => {
- vm = mountComponent(Component, {
- ...props,
- issueCount: 0,
- mergeRequestCount: 0,
- });
-
- const value = vm.text;
-
- expect(value).toContain('is not currently used');
- });
- });
-});
diff --git a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js
deleted file mode 100644
index 78c0070187c..00000000000
--- a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import promoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue';
-import eventHub from '~/pages/milestones/shared/event_hub';
-import axios from '~/lib/utils/axios_utils';
-
-describe('Promote milestone modal', () => {
- let vm;
- const Component = Vue.extend(promoteMilestoneModal);
- const milestoneMockData = {
- milestoneTitle: 'v1.0',
- url: `${gl.TEST_HOST}/dummy/promote/milestones`,
- groupName: 'group',
- };
-
- describe('Modal title and description', () => {
- beforeEach(() => {
- vm = mountComponent(Component, milestoneMockData);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('contains the proper description', () => {
- expect(vm.text).toContain(
- `Promoting ${milestoneMockData.milestoneTitle} will make it available for all projects inside ${milestoneMockData.groupName}.`,
- );
- });
-
- it('contains the correct title', () => {
- expect(vm.title).toEqual('Promote v1.0 to group milestone?');
- });
- });
-
- describe('When requesting a milestone promotion', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- ...milestoneMockData,
- });
- spyOn(eventHub, '$emit');
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('redirects when a milestone is promoted', done => {
- const responseURL = `${gl.TEST_HOST}/dummy/endpoint`;
- spyOn(axios, 'post').and.callFake(url => {
- expect(url).toBe(milestoneMockData.url);
- expect(eventHub.$emit).toHaveBeenCalledWith(
- 'promoteMilestoneModal.requestStarted',
- milestoneMockData.url,
- );
- return Promise.resolve({
- request: {
- responseURL,
- },
- });
- });
-
- vm.onSubmit()
- .then(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestFinished', {
- milestoneUrl: milestoneMockData.url,
- successful: true,
- });
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('displays an error if promoting a milestone failed', done => {
- const dummyError = new Error('promoting milestone failed');
- dummyError.response = { status: 500 };
- spyOn(axios, 'post').and.callFake(url => {
- expect(url).toBe(milestoneMockData.url);
- expect(eventHub.$emit).toHaveBeenCalledWith(
- 'promoteMilestoneModal.requestStarted',
- milestoneMockData.url,
- );
- return Promise.reject(dummyError);
- });
-
- vm.onSubmit()
- .catch(error => {
- expect(error).toBe(dummyError);
- expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestFinished', {
- milestoneUrl: milestoneMockData.url,
- successful: false,
- });
- })
- .then(done)
- .catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
deleted file mode 100644
index b20bc96f9be..00000000000
--- a/spec/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
+++ /dev/null
@@ -1,192 +0,0 @@
-import Vue from 'vue';
-import Translate from '~/vue_shared/translate';
-import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue';
-
-Vue.use(Translate);
-
-const IntervalPatternInputComponent = Vue.extend(IntervalPatternInput);
-const inputNameAttribute = 'schedule[cron]';
-
-const cronIntervalPresets = {
- everyDay: '0 4 * * *',
- everyWeek: '0 4 * * 0',
- everyMonth: '0 4 1 * *',
-};
-
-window.gl = window.gl || {};
-
-window.gl.pipelineScheduleFieldErrors = {
- updateFormValidityState: () => {},
-};
-
-describe('Interval Pattern Input Component', function() {
- describe('when prop initialCronInterval is passed (edit)', function() {
- describe('when prop initialCronInterval is custom', function() {
- beforeEach(function() {
- this.initialCronInterval = '1 2 3 4 5';
- this.intervalPatternComponent = new IntervalPatternInputComponent({
- propsData: {
- initialCronInterval: this.initialCronInterval,
- },
- }).$mount();
- });
-
- it('is initialized as a Vue component', function() {
- expect(this.intervalPatternComponent).toBeDefined();
- });
-
- it('prop initialCronInterval is set', function() {
- expect(this.intervalPatternComponent.initialCronInterval).toBe(this.initialCronInterval);
- });
-
- it('sets isEditable to true', function(done) {
- Vue.nextTick(() => {
- expect(this.intervalPatternComponent.isEditable).toBe(true);
- done();
- });
- });
- });
-
- describe('when prop initialCronInterval is preset', function() {
- beforeEach(function() {
- this.intervalPatternComponent = new IntervalPatternInputComponent({
- propsData: {
- inputNameAttribute,
- initialCronInterval: '0 4 * * *',
- },
- }).$mount();
- });
-
- it('is initialized as a Vue component', function() {
- expect(this.intervalPatternComponent).toBeDefined();
- });
-
- it('sets isEditable to false', function(done) {
- Vue.nextTick(() => {
- expect(this.intervalPatternComponent.isEditable).toBe(false);
- done();
- });
- });
- });
- });
-
- describe('when prop initialCronInterval is not passed (new)', function() {
- beforeEach(function() {
- this.intervalPatternComponent = new IntervalPatternInputComponent({
- propsData: {
- inputNameAttribute,
- },
- }).$mount();
- });
-
- it('is initialized as a Vue component', function() {
- expect(this.intervalPatternComponent).toBeDefined();
- });
-
- it('prop initialCronInterval is set', function() {
- const defaultInitialCronInterval = '';
-
- expect(this.intervalPatternComponent.initialCronInterval).toBe(defaultInitialCronInterval);
- });
-
- it('sets isEditable to true', function(done) {
- Vue.nextTick(() => {
- expect(this.intervalPatternComponent.isEditable).toBe(true);
- done();
- });
- });
- });
-
- describe('User Actions', function() {
- beforeEach(function() {
- // For an unknown reason, some browsers do not propagate click events
- // on radio buttons in a way Vue can register. So, we have to mount
- // to a fixture.
- setFixtures('<div id="my-mount"></div>');
-
- this.initialCronInterval = '1 2 3 4 5';
- this.intervalPatternComponent = new IntervalPatternInputComponent({
- propsData: {
- initialCronInterval: this.initialCronInterval,
- },
- }).$mount('#my-mount');
- });
-
- it('cronInterval is updated when everyday preset interval is selected', function(done) {
- this.intervalPatternComponent.$el.querySelector('#every-day').click();
-
- Vue.nextTick(() => {
- expect(this.intervalPatternComponent.cronInterval).toBe(cronIntervalPresets.everyDay);
- expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(
- cronIntervalPresets.everyDay,
- );
- done();
- });
- });
-
- it('cronInterval is updated when everyweek preset interval is selected', function(done) {
- this.intervalPatternComponent.$el.querySelector('#every-week').click();
-
- Vue.nextTick(() => {
- expect(this.intervalPatternComponent.cronInterval).toBe(cronIntervalPresets.everyWeek);
- expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(
- cronIntervalPresets.everyWeek,
- );
-
- done();
- });
- });
-
- it('cronInterval is updated when everymonth preset interval is selected', function(done) {
- this.intervalPatternComponent.$el.querySelector('#every-month').click();
-
- Vue.nextTick(() => {
- expect(this.intervalPatternComponent.cronInterval).toBe(cronIntervalPresets.everyMonth);
- expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(
- cronIntervalPresets.everyMonth,
- );
- done();
- });
- });
-
- it('only a space is added to cronInterval (trimmed later) when custom radio is selected', function(done) {
- this.intervalPatternComponent.$el.querySelector('#every-month').click();
- this.intervalPatternComponent.$el.querySelector('#custom').click();
-
- Vue.nextTick(() => {
- const intervalWithSpaceAppended = `${cronIntervalPresets.everyMonth} `;
-
- expect(this.intervalPatternComponent.cronInterval).toBe(intervalWithSpaceAppended);
- expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(
- intervalWithSpaceAppended,
- );
- done();
- });
- });
-
- it('text input is disabled when preset interval is selected', function(done) {
- this.intervalPatternComponent.$el.querySelector('#every-month').click();
-
- Vue.nextTick(() => {
- expect(this.intervalPatternComponent.isEditable).toBe(false);
- expect(
- this.intervalPatternComponent.$el.querySelector('.cron-interval-input').disabled,
- ).toBe(true);
- done();
- });
- });
-
- it('text input is enabled when custom is selected', function(done) {
- this.intervalPatternComponent.$el.querySelector('#every-month').click();
- this.intervalPatternComponent.$el.querySelector('#custom').click();
-
- Vue.nextTick(() => {
- expect(this.intervalPatternComponent.isEditable).toBe(true);
- expect(
- this.intervalPatternComponent.$el.querySelector('.cron-interval-input').disabled,
- ).toBe(false);
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
deleted file mode 100644
index ea809e1f170..00000000000
--- a/spec/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import Vue from 'vue';
-import Cookies from 'js-cookie';
-import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue';
-
-const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout);
-const cookieKey = 'pipeline_schedules_callout_dismissed';
-const docsUrl = 'help/ci/scheduled_pipelines';
-
-describe('Pipeline Schedule Callout', function() {
- beforeEach(() => {
- setFixtures(`
- <div id='pipeline-schedules-callout' data-docs-url=${docsUrl}></div>
- `);
- });
-
- describe('independent of cookies', () => {
- beforeEach(() => {
- this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
- });
-
- it('the component can be initialized', () => {
- expect(this.calloutComponent).toBeDefined();
- });
-
- it('correctly sets illustrationSvg', () => {
- expect(this.calloutComponent.illustrationSvg).toContain('<svg');
- });
-
- it('correctly sets docsUrl', () => {
- expect(this.calloutComponent.docsUrl).toContain(docsUrl);
- });
- });
-
- describe(`when ${cookieKey} cookie is set`, () => {
- beforeEach(() => {
- Cookies.set(cookieKey, true);
- this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
- });
-
- it('correctly sets calloutDismissed to true', () => {
- expect(this.calloutComponent.calloutDismissed).toBe(true);
- });
-
- it('does not render the callout', () => {
- expect(this.calloutComponent.$el.childNodes.length).toBe(0);
- });
- });
-
- describe('when cookie is not set', () => {
- beforeEach(() => {
- Cookies.remove(cookieKey);
- this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
- });
-
- it('correctly sets calloutDismissed to false', () => {
- expect(this.calloutComponent.calloutDismissed).toBe(false);
- });
-
- it('renders the callout container', () => {
- expect(this.calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull();
- });
-
- it('renders the callout svg', () => {
- expect(this.calloutComponent.$el.outerHTML).toContain('<svg');
- });
-
- it('renders the callout title', () => {
- expect(this.calloutComponent.$el.outerHTML).toContain('Scheduling Pipelines');
- });
-
- it('renders the callout text', () => {
- expect(this.calloutComponent.$el.outerHTML).toContain('runs pipelines in the future');
- });
-
- it('renders the documentation url', () => {
- expect(this.calloutComponent.$el.outerHTML).toContain(docsUrl);
- });
-
- it('updates calloutDismissed when close button is clicked', done => {
- this.calloutComponent.$el.querySelector('#dismiss-callout-btn').click();
-
- Vue.nextTick(() => {
- expect(this.calloutComponent.calloutDismissed).toBe(true);
- done();
- });
- });
-
- it('#dismissCallout updates calloutDismissed', done => {
- this.calloutComponent.dismissCallout();
-
- Vue.nextTick(() => {
- expect(this.calloutComponent.calloutDismissed).toBe(true);
- done();
- });
- });
-
- it('is hidden when close button is clicked', done => {
- this.calloutComponent.$el.querySelector('#dismiss-callout-btn').click();
-
- Vue.nextTick(() => {
- expect(this.calloutComponent.$el.childNodes.length).toBe(0);
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/pipelines/header_component_spec.js b/spec/javascripts/pipelines/header_component_spec.js
deleted file mode 100644
index 9043f30397d..00000000000
--- a/spec/javascripts/pipelines/header_component_spec.js
+++ /dev/null
@@ -1,108 +0,0 @@
-import Vue from 'vue';
-import headerComponent from '~/pipelines/components/header_component.vue';
-import eventHub from '~/pipelines/event_hub';
-
-describe('Pipeline details header', () => {
- let HeaderComponent;
- let vm;
- let props;
-
- beforeEach(() => {
- spyOn(eventHub, '$emit');
- HeaderComponent = Vue.extend(headerComponent);
-
- const threeWeeksAgo = new Date();
- threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
-
- props = {
- pipeline: {
- details: {
- status: {
- group: 'failed',
- icon: 'status_failed',
- label: 'failed',
- text: 'failed',
- details_path: 'path',
- },
- },
- id: 123,
- created_at: threeWeeksAgo.toISOString(),
- user: {
- web_url: 'path',
- name: 'Foo',
- username: 'foobar',
- email: 'foo@bar.com',
- avatar_url: 'link',
- },
- retry_path: 'retry',
- cancel_path: 'cancel',
- delete_path: 'delete',
- },
- isLoading: false,
- };
-
- vm = new HeaderComponent({ propsData: props }).$mount();
- });
-
- afterEach(() => {
- eventHub.$off();
- vm.$destroy();
- });
-
- const findDeleteModal = () => document.getElementById(headerComponent.DELETE_MODAL_ID);
- const findDeleteModalSubmit = () =>
- [...findDeleteModal().querySelectorAll('.btn')].find(x => x.textContent === 'Delete pipeline');
-
- it('should render provided pipeline info', () => {
- expect(
- vm.$el
- .querySelector('.header-main-content')
- .textContent.replace(/\s+/g, ' ')
- .trim(),
- ).toContain('failed Pipeline #123 triggered 3 weeks ago by Foo');
- });
-
- describe('action buttons', () => {
- it('should not trigger eventHub when nothing happens', () => {
- expect(eventHub.$emit).not.toHaveBeenCalled();
- });
-
- it('should call postAction when retry button action is clicked', () => {
- vm.$el.querySelector('.js-retry-button').click();
-
- expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry');
- });
-
- it('should call postAction when cancel button action is clicked', () => {
- vm.$el.querySelector('.js-btn-cancel-pipeline').click();
-
- expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel');
- });
-
- it('does not show delete modal', () => {
- expect(findDeleteModal()).not.toBeVisible();
- });
-
- describe('when delete button action is clicked', () => {
- beforeEach(done => {
- vm.$el.querySelector('.js-btn-delete-pipeline').click();
-
- // Modal needs two ticks to show
- vm.$nextTick()
- .then(() => vm.$nextTick())
- .then(done)
- .catch(done.fail);
- });
-
- it('should show delete modal', () => {
- expect(findDeleteModal()).toBeVisible();
- });
-
- it('should call delete when modal is submitted', () => {
- findDeleteModalSubmit().click();
-
- expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete');
- });
- });
- });
-});
diff --git a/spec/javascripts/pipelines/linked_pipelines_mock.json b/spec/javascripts/pipelines/linked_pipelines_mock.json
deleted file mode 100644
index 60e214ddc32..00000000000
--- a/spec/javascripts/pipelines/linked_pipelines_mock.json
+++ /dev/null
@@ -1,3535 +0,0 @@
-{
- "id": 23211253,
- "user": {
- "id": 3585,
- "name": "Achilleas Pipinellis",
- "username": "axil",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png",
- "web_url": "https://gitlab.com/axil",
- "status_tooltip_html": "\u003cspan class=\"user-status-emoji has-tooltip\" title=\"I like pizza\" data-html=\"true\" data-placement=\"top\"\u003e\u003cgl-emoji title=\"slice of pizza\" data-name=\"pizza\" data-unicode-version=\"6.0\"\u003e🍕\u003c/gl-emoji\u003e\u003c/span\u003e",
- "path": "/axil"
- },
- "active": false,
- "coverage": null,
- "source": "push",
- "created_at": "2018-06-05T11:31:30.452Z",
- "updated_at": "2018-10-31T16:35:31.305Z",
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253",
- "flags": {
- "latest": false,
- "stuck": false,
- "auto_devops": false,
- "merge_request": false,
- "yaml_errors": false,
- "retryable": false,
- "cancelable": false,
- "failure_reason": false
- },
- "details": {
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "duration": 53,
- "finished_at": "2018-10-31T16:35:31.299Z",
- "stages": [
- {
- "name": "prebuild",
- "title": "prebuild: passed",
- "groups": [
- {
- "name": "review-docs-deploy",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "manual play action",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 72469032,
- "name": "review-docs-deploy",
- "started": "2018-10-31T16:34:58.778Z",
- "archived": false,
- "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
- "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/retry",
- "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-06-05T11:31:30.495Z",
- "updated_at": "2018-10-31T16:35:31.251Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "manual play action",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild",
- "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=prebuild"
- },
- {
- "name": "test",
- "title": "test: passed",
- "groups": [
- {
- "name": "docs check links",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 72469033,
- "name": "docs check links",
- "started": "2018-06-05T11:31:33.240Z",
- "archived": false,
- "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
- "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-06-05T11:31:30.627Z",
- "updated_at": "2018-06-05T11:31:54.363Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#test",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253#test",
- "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=test"
- },
- {
- "name": "cleanup",
- "title": "cleanup: skipped",
- "groups": [
- {
- "name": "review-docs-cleanup",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual stop action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "stop",
- "title": "Stop",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "method": "post",
- "button_title": "Stop this environment"
- }
- },
- "jobs": [
- {
- "id": 72469034,
- "name": "review-docs-cleanup",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
- "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-06-05T11:31:30.760Z",
- "updated_at": "2018-06-05T11:31:56.037Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual stop action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "stop",
- "title": "Stop",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "method": "post",
- "button_title": "Stop this environment"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup",
- "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=cleanup"
- }
- ],
- "artifacts": [],
- "manual_actions": [
- {
- "name": "review-docs-cleanup",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "review-docs-deploy",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "playable": true,
- "scheduled": false
- }
- ],
- "scheduled_actions": []
- },
- "ref": {
- "name": "docs/add-development-guide-to-readme",
- "path": "/gitlab-org/gitlab-runner/commits/docs/add-development-guide-to-readme",
- "tag": false,
- "branch": true,
- "merge_request": false
- },
- "commit": {
- "id": "8083eb0a920572214d0dccedd7981f05d535ad46",
- "short_id": "8083eb0a",
- "title": "Add link to development guide in readme",
- "created_at": "2018-06-05T11:30:48.000Z",
- "parent_ids": ["1d7cf79b5a1a2121b9474ac20d61c1b8f621289d"],
- "message": "Add link to development guide in readme\n\nCloses https://gitlab.com/gitlab-org/gitlab-runner/issues/3122\n",
- "author_name": "Achilleas Pipinellis",
- "author_email": "axil@gitlab.com",
- "authored_date": "2018-06-05T11:30:48.000Z",
- "committer_name": "Achilleas Pipinellis",
- "committer_email": "axil@gitlab.com",
- "committed_date": "2018-06-05T11:30:48.000Z",
- "author": {
- "id": 3585,
- "name": "Achilleas Pipinellis",
- "username": "axil",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png",
- "web_url": "https://gitlab.com/axil",
- "status_tooltip_html": null,
- "path": "/axil"
- },
- "author_gravatar_url": "https://secure.gravatar.com/avatar/1d37af00eec153a8333a4ce18e9aea41?s=80\u0026d=identicon",
- "commit_url": "https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46",
- "commit_path": "/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46"
- },
- "project": {
- "id": 1794617
- },
- "triggered_by": {
- "id": 12,
- "user": {
- "id": 376774,
- "name": "Alessio Caiazza",
- "username": "nolith",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
- "web_url": "https://gitlab.com/nolith",
- "status_tooltip_html": null,
- "path": "/nolith"
- },
- "active": false,
- "coverage": null,
- "source": "pipeline",
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "details": {
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
- },
- "duration": 118,
- "finished_at": "2018-10-31T16:41:40.615Z",
- "stages": [
- {
- "name": "build-images",
- "title": "build-images: skipped",
- "groups": [
- {
- "name": "image:bootstrap",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 11421321982853,
- "name": "image:bootstrap",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
- "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.704Z",
- "updated_at": "2018-10-31T16:35:24.118Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- },
- {
- "name": "image:builder-onbuild",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 1149822131854,
- "name": "image:builder-onbuild",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
- "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.728Z",
- "updated_at": "2018-10-31T16:35:24.070Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- },
- {
- "name": "image:nginx-onbuild",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 11498285523424,
- "name": "image:nginx-onbuild",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
- "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.753Z",
- "updated_at": "2018-10-31T16:35:24.033Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
- "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images"
- },
- {
- "name": "build",
- "title": "build: failed",
- "groups": [
- {
- "name": "compile_dev",
- "size": 1,
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed - (script failure)",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 1149846949786,
- "name": "compile_dev",
- "started": "2018-10-31T16:39:41.598Z",
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
- "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-10-31T16:39:41.138Z",
- "updated_at": "2018-10-31T16:41:40.072Z",
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed - (script failure)",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "recoverable": false
- }
- ]
- }
- ],
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
- },
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
- "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build"
- },
- {
- "name": "deploy",
- "title": "deploy: skipped",
- "groups": [
- {
- "name": "review",
- "size": 1,
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "jobs": [
- {
- "id": 11498282342357,
- "name": "review",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.805Z",
- "updated_at": "2018-10-31T16:41:40.569Z",
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- }
- }
- ]
- },
- {
- "name": "review_stop",
- "size": 1,
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "jobs": [
- {
- "id": 114982858,
- "name": "review_stop",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.840Z",
- "updated_at": "2018-10-31T16:41:40.480Z",
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
- "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy"
- }
- ],
- "artifacts": [],
- "manual_actions": [
- {
- "name": "image:bootstrap",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "image:builder-onbuild",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "image:nginx-onbuild",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "review_stop",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play",
- "playable": false,
- "scheduled": false
- }
- ],
- "scheduled_actions": []
- },
- "project": {
- "id": 1794617,
- "name": "Test",
- "full_path": "/gitlab-com/gitlab-docs",
- "full_name": "GitLab.com / GitLab Docs"
- },
- "triggered_by": {
- "id": 349932310342451,
- "user": {
- "id": 376774,
- "name": "Alessio Caiazza",
- "username": "nolith",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
- "web_url": "https://gitlab.com/nolith",
- "status_tooltip_html": null,
- "path": "/nolith"
- },
- "active": false,
- "coverage": null,
- "source": "pipeline",
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "details": {
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
- },
- "duration": 118,
- "finished_at": "2018-10-31T16:41:40.615Z",
- "stages": [
- {
- "name": "build-images",
- "title": "build-images: skipped",
- "groups": [
- {
- "name": "image:bootstrap",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 11421321982853,
- "name": "image:bootstrap",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
- "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.704Z",
- "updated_at": "2018-10-31T16:35:24.118Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- },
- {
- "name": "image:builder-onbuild",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 1149822131854,
- "name": "image:builder-onbuild",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
- "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.728Z",
- "updated_at": "2018-10-31T16:35:24.070Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- },
- {
- "name": "image:nginx-onbuild",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 11498285523424,
- "name": "image:nginx-onbuild",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
- "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.753Z",
- "updated_at": "2018-10-31T16:35:24.033Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
- "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images"
- },
- {
- "name": "build",
- "title": "build: failed",
- "groups": [
- {
- "name": "compile_dev",
- "size": 1,
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed - (script failure)",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 1149846949786,
- "name": "compile_dev",
- "started": "2018-10-31T16:39:41.598Z",
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
- "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-10-31T16:39:41.138Z",
- "updated_at": "2018-10-31T16:41:40.072Z",
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed - (script failure)",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "recoverable": false
- }
- ]
- }
- ],
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
- },
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
- "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build"
- },
- {
- "name": "deploy",
- "title": "deploy: skipped",
- "groups": [
- {
- "name": "review",
- "size": 1,
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "jobs": [
- {
- "id": 11498282342357,
- "name": "review",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.805Z",
- "updated_at": "2018-10-31T16:41:40.569Z",
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- }
- }
- ]
- },
- {
- "name": "review_stop",
- "size": 1,
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "jobs": [
- {
- "id": 114982858,
- "name": "review_stop",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.840Z",
- "updated_at": "2018-10-31T16:41:40.480Z",
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
- "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy"
- }
- ],
- "artifacts": [],
- "manual_actions": [
- {
- "name": "image:bootstrap",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "image:builder-onbuild",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "image:nginx-onbuild",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "review_stop",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play",
- "playable": false,
- "scheduled": false
- }
- ],
- "scheduled_actions": []
- },
- "project": {
- "id": 1794617,
- "name": "GitLab Docs",
- "full_path": "/gitlab-com/gitlab-docs",
- "full_name": "GitLab.com / GitLab Docs"
- }
- },
- "triggered": []
- },
- "triggered": [
- {
- "id": 34993051,
- "user": {
- "id": 376774,
- "name": "Alessio Caiazza",
- "username": "nolith",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
- "web_url": "https://gitlab.com/nolith",
- "status_tooltip_html": null,
- "path": "/nolith"
- },
- "active": false,
- "coverage": null,
- "source": "pipeline",
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "details": {
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
- },
- "duration": 118,
- "finished_at": "2018-10-31T16:41:40.615Z",
- "stages": [
- {
- "name": "build-images",
- "title": "build-images: skipped",
- "groups": [
- {
- "name": "image:bootstrap",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 114982853,
- "name": "image:bootstrap",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
- "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.704Z",
- "updated_at": "2018-10-31T16:35:24.118Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- },
- {
- "name": "image:builder-onbuild",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 114982854,
- "name": "image:builder-onbuild",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
- "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.728Z",
- "updated_at": "2018-10-31T16:35:24.070Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- },
- {
- "name": "image:nginx-onbuild",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 114982855,
- "name": "image:nginx-onbuild",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
- "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.753Z",
- "updated_at": "2018-10-31T16:35:24.033Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
- "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images"
- },
- {
- "name": "build",
- "title": "build: failed",
- "groups": [
- {
- "name": "compile_dev",
- "size": 1,
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed - (script failure)",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 114984694,
- "name": "compile_dev",
- "started": "2018-10-31T16:39:41.598Z",
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
- "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-10-31T16:39:41.138Z",
- "updated_at": "2018-10-31T16:41:40.072Z",
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed - (script failure)",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "recoverable": false
- }
- ]
- }
- ],
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
- },
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
- "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build"
- },
- {
- "name": "deploy",
- "title": "deploy: skipped",
- "groups": [
- {
- "name": "review",
- "size": 1,
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "jobs": [
- {
- "id": 114982857,
- "name": "review",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.805Z",
- "updated_at": "2018-10-31T16:41:40.569Z",
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- }
- }
- ]
- },
- {
- "name": "review_stop",
- "size": 1,
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "jobs": [
- {
- "id": 114982858,
- "name": "review_stop",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.840Z",
- "updated_at": "2018-10-31T16:41:40.480Z",
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
- "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy"
- }
- ],
- "artifacts": [],
- "manual_actions": [
- {
- "name": "image:bootstrap",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "image:builder-onbuild",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "image:nginx-onbuild",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "review_stop",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play",
- "playable": false,
- "scheduled": false
- }
- ],
- "scheduled_actions": []
- },
- "project": {
- "id": 1794617,
- "name": "GitLab Docs",
- "full_path": "/gitlab-com/gitlab-docs",
- "full_name": "GitLab.com / GitLab Docs"
- }
- },
- {
- "id": 34993052,
- "user": {
- "id": 376774,
- "name": "Alessio Caiazza",
- "username": "nolith",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
- "web_url": "https://gitlab.com/nolith",
- "status_tooltip_html": null,
- "path": "/nolith"
- },
- "active": false,
- "coverage": null,
- "source": "pipeline",
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "details": {
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
- },
- "duration": 118,
- "finished_at": "2018-10-31T16:41:40.615Z",
- "stages": [
- {
- "name": "build-images",
- "title": "build-images: skipped",
- "groups": [
- {
- "name": "image:bootstrap",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 114982853,
- "name": "image:bootstrap",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
- "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.704Z",
- "updated_at": "2018-10-31T16:35:24.118Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- },
- {
- "name": "image:builder-onbuild",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 114982854,
- "name": "image:builder-onbuild",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
- "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.728Z",
- "updated_at": "2018-10-31T16:35:24.070Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- },
- {
- "name": "image:nginx-onbuild",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 1224982855,
- "name": "image:nginx-onbuild",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
- "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.753Z",
- "updated_at": "2018-10-31T16:35:24.033Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual play action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
- "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images"
- },
- {
- "name": "build",
- "title": "build: failed",
- "groups": [
- {
- "name": "compile_dev",
- "size": 1,
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed - (script failure)",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 1123984694,
- "name": "compile_dev",
- "started": "2018-10-31T16:39:41.598Z",
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
- "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-10-31T16:39:41.138Z",
- "updated_at": "2018-10-31T16:41:40.072Z",
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed - (script failure)",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "recoverable": false
- }
- ]
- }
- ],
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
- },
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
- "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build"
- },
- {
- "name": "deploy",
- "title": "deploy: skipped",
- "groups": [
- {
- "name": "review",
- "size": 1,
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "jobs": [
- {
- "id": 1143232982857,
- "name": "review",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.805Z",
- "updated_at": "2018-10-31T16:41:40.569Z",
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- }
- }
- ]
- },
- {
- "name": "review_stop",
- "size": 1,
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "jobs": [
- {
- "id": 114921313182858,
- "name": "review_stop",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-10-31T16:35:23.840Z",
- "updated_at": "2018-10-31T16:41:40.480Z",
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
- "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy"
- }
- ],
- "artifacts": [],
- "manual_actions": [
- {
- "name": "image:bootstrap",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "image:builder-onbuild",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "image:nginx-onbuild",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "review_stop",
- "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play",
- "playable": false,
- "scheduled": false
- }
- ],
- "scheduled_actions": []
- },
- "project": {
- "id": 1794617,
- "name": "GitLab Docs",
- "full_path": "/gitlab-com/gitlab-docs",
- "full_name": "GitLab.com / GitLab Docs"
- },
- "triggered": [
- {
- "id": 26,
- "user": null,
- "active": false,
- "coverage": null,
- "source": "push",
- "created_at": "2019-01-06T17:48:37.599Z",
- "updated_at": "2019-01-06T17:48:38.371Z",
- "path": "/h5bp/html5-boilerplate/pipelines/26",
- "flags": {
- "latest": true,
- "stuck": false,
- "auto_devops": false,
- "merge_request": false,
- "yaml_errors": false,
- "retryable": true,
- "cancelable": false,
- "failure_reason": false
- },
- "details": {
- "status": {
- "icon": "status_warning",
- "text": "passed",
- "label": "passed with warnings",
- "group": "success-with-warnings",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/pipelines/26",
- "illustration": null,
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "duration": null,
- "finished_at": "2019-01-06T17:48:38.370Z",
- "stages": [
- {
- "name": "build",
- "title": "build: passed",
- "groups": [
- {
- "name": "build:linux",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/526",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/526/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 526,
- "name": "build:linux",
- "started": "2019-01-06T08:48:20.236Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/526",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/526/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:37.806Z",
- "updated_at": "2019-01-06T17:48:37.806Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/526",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/526/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- },
- {
- "name": "build:osx",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/527",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/527/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 527,
- "name": "build:osx",
- "started": "2019-01-06T07:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/527",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/527/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:37.846Z",
- "updated_at": "2019-01-06T17:48:37.846Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/527",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/527/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/pipelines/26#build",
- "illustration": null,
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/h5bp/html5-boilerplate/pipelines/26#build",
- "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=build"
- },
- {
- "name": "test",
- "title": "test: passed with warnings",
- "groups": [
- {
- "name": "jenkins",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": null,
- "group": "success",
- "tooltip": null,
- "has_details": false,
- "details_path": null,
- "illustration": null,
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "jobs": [
- {
- "id": 546,
- "name": "jenkins",
- "started": "2019-01-06T11:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/546",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:38.359Z",
- "updated_at": "2019-01-06T17:48:38.359Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": null,
- "group": "success",
- "tooltip": null,
- "has_details": false,
- "details_path": null,
- "illustration": null,
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- }
- }
- ]
- },
- {
- "name": "rspec:linux",
- "size": 3,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": false,
- "details_path": null,
- "illustration": null,
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "jobs": [
- {
- "id": 528,
- "name": "rspec:linux 0 3",
- "started": "2019-01-06T09:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/528",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:37.885Z",
- "updated_at": "2019-01-06T17:48:37.885Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/528",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- },
- {
- "id": 529,
- "name": "rspec:linux 1 3",
- "started": "2019-01-06T09:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/529",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/529/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:37.907Z",
- "updated_at": "2019-01-06T17:48:37.907Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/529",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/529/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- },
- {
- "id": 530,
- "name": "rspec:linux 2 3",
- "started": "2019-01-06T09:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/530",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/530/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:37.927Z",
- "updated_at": "2019-01-06T17:48:37.927Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/530",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/530/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- },
- {
- "name": "rspec:osx",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/535",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/535/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 535,
- "name": "rspec:osx",
- "started": "2019-01-06T09:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/535",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/535/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:38.018Z",
- "updated_at": "2019-01-06T17:48:38.018Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/535",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/535/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- },
- {
- "name": "rspec:windows",
- "size": 3,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": false,
- "details_path": null,
- "illustration": null,
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "jobs": [
- {
- "id": 531,
- "name": "rspec:windows 0 3",
- "started": "2019-01-06T09:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/531",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/531/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:37.944Z",
- "updated_at": "2019-01-06T17:48:37.944Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/531",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/531/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- },
- {
- "id": 532,
- "name": "rspec:windows 1 3",
- "started": "2019-01-06T09:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/532",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/532/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:37.962Z",
- "updated_at": "2019-01-06T17:48:37.962Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/532",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/532/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- },
- {
- "id": 534,
- "name": "rspec:windows 2 3",
- "started": "2019-01-06T09:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/534",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/534/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:37.999Z",
- "updated_at": "2019-01-06T17:48:37.999Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/534",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/534/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- },
- {
- "name": "spinach:linux",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/536",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/536/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 536,
- "name": "spinach:linux",
- "started": "2019-01-06T09:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/536",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/536/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:38.050Z",
- "updated_at": "2019-01-06T17:48:38.050Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/536",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/536/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- },
- {
- "name": "spinach:osx",
- "size": 1,
- "status": {
- "icon": "status_warning",
- "text": "failed",
- "label": "failed (allowed to fail)",
- "group": "failed-with-warnings",
- "tooltip": "failed - (unknown failure) (allowed to fail)",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/537",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/537/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 537,
- "name": "spinach:osx",
- "started": "2019-01-06T09:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/537",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/537/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:38.069Z",
- "updated_at": "2019-01-06T17:48:38.069Z",
- "status": {
- "icon": "status_warning",
- "text": "failed",
- "label": "failed (allowed to fail)",
- "group": "failed-with-warnings",
- "tooltip": "failed - (unknown failure) (allowed to fail)",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/537",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/537/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "callout_message": "There is an unknown failure, please try again",
- "recoverable": true
- }
- ]
- }
- ],
- "status": {
- "icon": "status_warning",
- "text": "passed",
- "label": "passed with warnings",
- "group": "success-with-warnings",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/pipelines/26#test",
- "illustration": null,
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/h5bp/html5-boilerplate/pipelines/26#test",
- "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=test"
- },
- {
- "name": "security",
- "title": "security: passed",
- "groups": [
- {
- "name": "container_scanning",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/541",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/541/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 541,
- "name": "container_scanning",
- "started": "2019-01-06T09:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/541",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/541/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:38.186Z",
- "updated_at": "2019-01-06T17:48:38.186Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/541",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/541/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- },
- {
- "name": "dast",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/538",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/538/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 538,
- "name": "dast",
- "started": "2019-01-06T09:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/538",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/538/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:38.087Z",
- "updated_at": "2019-01-06T17:48:38.087Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/538",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/538/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- },
- {
- "name": "dependency_scanning",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/540",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/540/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 540,
- "name": "dependency_scanning",
- "started": "2019-01-06T09:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/540",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/540/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:38.153Z",
- "updated_at": "2019-01-06T17:48:38.153Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/540",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/540/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- },
- {
- "name": "sast",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/539",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/539/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 539,
- "name": "sast",
- "started": "2019-01-06T09:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/539",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/539/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:38.121Z",
- "updated_at": "2019-01-06T17:48:38.121Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/539",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/539/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/pipelines/26#security",
- "illustration": null,
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/h5bp/html5-boilerplate/pipelines/26#security",
- "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=security"
- },
- {
- "name": "deploy",
- "title": "deploy: passed",
- "groups": [
- {
- "name": "production",
- "size": 1,
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/544",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "jobs": [
- {
- "id": 544,
- "name": "production",
- "started": null,
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/544",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:38.313Z",
- "updated_at": "2019-01-06T17:48:38.313Z",
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/544",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- }
- }
- ]
- },
- {
- "name": "staging",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/542",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/542/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 542,
- "name": "staging",
- "started": "2019-01-06T11:48:20.237Z",
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/542",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/542/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:38.219Z",
- "updated_at": "2019-01-06T17:48:38.219Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/542",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/h5bp/html5-boilerplate/-/jobs/542/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- },
- {
- "name": "stop staging",
- "size": 1,
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/543",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "jobs": [
- {
- "id": 543,
- "name": "stop staging",
- "started": null,
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/543",
- "playable": false,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:38.283Z",
- "updated_at": "2019-01-06T17:48:38.283Z",
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/543",
- "illustration": {
- "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job has been skipped"
- },
- "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/pipelines/26#deploy",
- "illustration": null,
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/h5bp/html5-boilerplate/pipelines/26#deploy",
- "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=deploy"
- },
- {
- "name": "notify",
- "title": "notify: passed",
- "groups": [
- {
- "name": "slack",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "manual play action",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/545",
- "illustration": {
- "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/h5bp/html5-boilerplate/-/jobs/545/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 545,
- "name": "slack",
- "started": null,
- "archived": false,
- "build_path": "/h5bp/html5-boilerplate/-/jobs/545",
- "retry_path": "/h5bp/html5-boilerplate/-/jobs/545/retry",
- "play_path": "/h5bp/html5-boilerplate/-/jobs/545/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2019-01-06T17:48:38.341Z",
- "updated_at": "2019-01-06T17:48:38.341Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "manual play action",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/-/jobs/545",
- "illustration": {
- "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/h5bp/html5-boilerplate/-/jobs/545/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/h5bp/html5-boilerplate/pipelines/26#notify",
- "illustration": null,
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/h5bp/html5-boilerplate/pipelines/26#notify",
- "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=notify"
- }
- ],
- "artifacts": [
- {
- "name": "build:linux",
- "expired": null,
- "expire_at": null,
- "path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/download",
- "browse_path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/browse"
- },
- {
- "name": "build:osx",
- "expired": null,
- "expire_at": null,
- "path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/download",
- "browse_path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/browse"
- }
- ],
- "manual_actions": [
- {
- "name": "stop staging",
- "path": "/h5bp/html5-boilerplate/-/jobs/543/play",
- "playable": false,
- "scheduled": false
- },
- {
- "name": "production",
- "path": "/h5bp/html5-boilerplate/-/jobs/544/play",
- "playable": false,
- "scheduled": false
- },
- {
- "name": "slack",
- "path": "/h5bp/html5-boilerplate/-/jobs/545/play",
- "playable": true,
- "scheduled": false
- }
- ],
- "scheduled_actions": []
- },
- "ref": {
- "name": "master",
- "path": "/h5bp/html5-boilerplate/commits/master",
- "tag": false,
- "branch": true,
- "merge_request": false
- },
- "commit": {
- "id": "bad98c453eab56d20057f3929989251d45cd1a8b",
- "short_id": "bad98c45",
- "title": "remove instances of shrink-to-fit=no (#2103)",
- "created_at": "2018-12-17T20:52:18.000Z",
- "parent_ids": ["49130f6cfe9ff1f749015d735649a2bc6f66cf3a"],
- "message": "remove instances of shrink-to-fit=no (#2103)\n\ncloses #2102\r\n\r\nPer my findings, the need for it as a default was rectified with the release of iOS 9.3, where the viewport no longer shrunk to accommodate overflow, as was introduced in iOS 9.",
- "author_name": "Scott O'Hara",
- "author_email": "scottaohara@users.noreply.github.com",
- "authored_date": "2018-12-17T20:52:18.000Z",
- "committer_name": "Rob Larsen",
- "committer_email": "rob@drunkenfist.com",
- "committed_date": "2018-12-17T20:52:18.000Z",
- "author": null,
- "author_gravatar_url": "https://www.gravatar.com/avatar/6d597df7cf998d16cbe00ccac063b31e?s=80\u0026d=identicon",
- "commit_url": "http://localhost:3001/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b",
- "commit_path": "/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b"
- },
- "retry_path": "/h5bp/html5-boilerplate/pipelines/26/retry",
- "triggered_by": {
- "id": 4,
- "user": null,
- "active": false,
- "coverage": null,
- "source": "push",
- "path": "/gitlab-org/gitlab-test/pipelines/4",
- "details": {
- "status": {
- "icon": "status_warning",
- "text": "passed",
- "label": "passed with warnings",
- "group": "success-with-warnings",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-test/pipelines/4",
- "illustration": null,
- "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- }
- },
- "project": {
- "id": 1,
- "name": "Gitlab Test",
- "full_path": "/gitlab-org/gitlab-test",
- "full_name": "Gitlab Org / Gitlab Test"
- }
- },
- "triggered": [],
- "project": {
- "id": 1794617,
- "name": "GitLab Docs",
- "full_path": "/gitlab-com/gitlab-docs",
- "full_name": "GitLab.com / GitLab Docs"
- }
- }
- ]
- }
- ]
-}
diff --git a/spec/javascripts/pipelines/mock_data.js b/spec/javascripts/pipelines/mock_data.js
deleted file mode 100644
index f876987cd88..00000000000
--- a/spec/javascripts/pipelines/mock_data.js
+++ /dev/null
@@ -1,423 +0,0 @@
-export const pipelineWithStages = {
- id: 20333396,
- user: {
- id: 128633,
- name: 'Rémy Coutable',
- username: 'rymai',
- state: 'active',
- avatar_url:
- 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon',
- web_url: 'https://gitlab.com/rymai',
- path: '/rymai',
- },
- active: true,
- coverage: '58.24',
- source: 'push',
- created_at: '2018-04-11T14:04:53.881Z',
- updated_at: '2018-04-11T14:05:00.792Z',
- path: '/gitlab-org/gitlab/pipelines/20333396',
- flags: {
- latest: true,
- stuck: false,
- auto_devops: false,
- yaml_errors: false,
- retryable: false,
- cancelable: true,
- failure_reason: false,
- },
- details: {
- status: {
- icon: 'status_running',
- text: 'running',
- label: 'running',
- group: 'running',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico',
- },
- duration: null,
- finished_at: null,
- stages: [
- {
- name: 'build',
- title: 'build: skipped',
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396#build',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_skipped-a2eee568a5bffdb494050c7b62dde241de9189280836288ac8923d369f16222d.ico',
- },
- path: '/gitlab-org/gitlab/pipelines/20333396#build',
- dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=build',
- },
- {
- name: 'prepare',
- title: 'prepare: passed',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396#prepare',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_success-26f59841becbef8c6fe414e9e74471d8bfd6a91b5855c19fe7f5923a40a7da47.ico',
- },
- path: '/gitlab-org/gitlab/pipelines/20333396#prepare',
- dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=prepare',
- },
- {
- name: 'test',
- title: 'test: running',
- status: {
- icon: 'status_running',
- text: 'running',
- label: 'running',
- group: 'running',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396#test',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico',
- },
- path: '/gitlab-org/gitlab/pipelines/20333396#test',
- dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=test',
- },
- {
- name: 'post-test',
- title: 'post-test: created',
- status: {
- icon: 'status_created',
- text: 'created',
- label: 'created',
- group: 'created',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396#post-test',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico',
- },
- path: '/gitlab-org/gitlab/pipelines/20333396#post-test',
- dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-test',
- },
- {
- name: 'pages',
- title: 'pages: created',
- status: {
- icon: 'status_created',
- text: 'created',
- label: 'created',
- group: 'created',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396#pages',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico',
- },
- path: '/gitlab-org/gitlab/pipelines/20333396#pages',
- dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=pages',
- },
- {
- name: 'post-cleanup',
- title: 'post-cleanup: created',
- status: {
- icon: 'status_created',
- text: 'created',
- label: 'created',
- group: 'created',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico',
- },
- path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup',
- dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-cleanup',
- },
- ],
- artifacts: [
- {
- name: 'gitlab:assets:compile',
- expired: false,
- expire_at: '2018-05-12T14:22:54.730Z',
- path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/browse',
- },
- {
- name: 'rspec-mysql 12 28',
- expired: false,
- expire_at: '2018-05-12T14:22:45.136Z',
- path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/browse',
- },
- {
- name: 'rspec-mysql 6 28',
- expired: false,
- expire_at: '2018-05-12T14:22:41.523Z',
- path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/browse',
- },
- {
- name: 'rspec-pg geo 0 1',
- expired: false,
- expire_at: '2018-05-12T14:22:13.287Z',
- path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/browse',
- },
- {
- name: 'rspec-mysql 0 28',
- expired: false,
- expire_at: '2018-05-12T14:22:06.834Z',
- path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/browse',
- },
- {
- name: 'spinach-mysql 0 2',
- expired: false,
- expire_at: '2018-05-12T14:21:51.409Z',
- path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/browse',
- },
- {
- name: 'karma',
- expired: false,
- expire_at: '2018-05-12T14:21:20.934Z',
- path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/browse',
- },
- {
- name: 'spinach-pg 0 2',
- expired: false,
- expire_at: '2018-05-12T14:20:01.028Z',
- path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/browse',
- },
- {
- name: 'spinach-pg 1 2',
- expired: false,
- expire_at: '2018-05-12T14:19:04.336Z',
- path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/browse',
- },
- {
- name: 'sast',
- expired: null,
- expire_at: null,
- path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/download',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/browse',
- },
- {
- name: 'code_quality',
- expired: false,
- expire_at: '2018-04-18T14:16:24.484Z',
- path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/browse',
- },
- {
- name: 'cache gems',
- expired: null,
- expire_at: null,
- path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/download',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/browse',
- },
- {
- name: 'dependency_scanning',
- expired: null,
- expire_at: null,
- path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/download',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/browse',
- },
- {
- name: 'compile-assets',
- expired: false,
- expire_at: '2018-04-18T14:12:07.638Z',
- path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/browse',
- },
- {
- name: 'setup-test-env',
- expired: false,
- expire_at: '2018-04-18T14:10:27.024Z',
- path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/browse',
- },
- {
- name: 'retrieve-tests-metadata',
- expired: false,
- expire_at: '2018-05-12T14:06:35.926Z',
- path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/browse',
- },
- ],
- manual_actions: [
- {
- name: 'package-and-qa',
- path: '/gitlab-org/gitlab/-/jobs/62411330/play',
- playable: true,
- },
- {
- name: 'review-docs-deploy',
- path: '/gitlab-org/gitlab/-/jobs/62411332/play',
- playable: true,
- },
- ],
- },
- ref: {
- name: 'master',
- path: '/gitlab-org/gitlab/commits/master',
- tag: false,
- branch: true,
- },
- commit: {
- id: 'e6a2885c503825792cb8a84a8731295e361bd059',
- short_id: 'e6a2885c',
- title: "Merge branch 'ce-to-ee-2018-04-11' into 'master'",
- created_at: '2018-04-11T14:04:39.000Z',
- parent_ids: [
- '5d9b5118f6055f72cff1a82b88133609912f2c1d',
- '6fdc6ee76a8062fe41b1a33f7c503334a6ebdc02',
- ],
- message:
- "Merge branch 'ce-to-ee-2018-04-11' into 'master'\n\nCE upstream - 2018-04-11 12:26 UTC\n\nSee merge request gitlab-org/gitlab-ee!5326",
- author_name: 'Rémy Coutable',
- author_email: 'remy@rymai.me',
- authored_date: '2018-04-11T14:04:39.000Z',
- committer_name: 'Rémy Coutable',
- committer_email: 'remy@rymai.me',
- committed_date: '2018-04-11T14:04:39.000Z',
- author: {
- id: 128633,
- name: 'Rémy Coutable',
- username: 'rymai',
- state: 'active',
- avatar_url:
- 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon',
- web_url: 'https://gitlab.com/rymai',
- path: '/rymai',
- },
- author_gravatar_url:
- 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon',
- commit_url:
- 'https://gitlab.com/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059',
- commit_path: '/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059',
- },
- cancel_path: '/gitlab-org/gitlab/pipelines/20333396/cancel',
- triggered_by: null,
- triggered: [],
-};
-
-export const stageReply = {
- name: 'deploy',
- title: 'deploy: running',
- latest_statuses: [
- {
- id: 928,
- name: 'stop staging',
- started: false,
- build_path: '/twitter/flight/-/jobs/928',
- cancel_path: '/twitter/flight/-/jobs/928/cancel',
- playable: false,
- created_at: '2018-04-04T20:02:02.728Z',
- updated_at: '2018-04-04T20:02:02.766Z',
- status: {
- icon: 'status_pending',
- text: 'pending',
- label: 'pending',
- group: 'pending',
- tooltip: 'pending',
- has_details: true,
- details_path: '/twitter/flight/-/jobs/928',
- favicon:
- '/assets/ci_favicons/dev/favicon_status_pending-db32e1faf94b9f89530ac519790920d1f18ea8f6af6cd2e0a26cd6840cacf101.ico',
- action: {
- icon: 'cancel',
- title: 'Cancel',
- path: '/twitter/flight/-/jobs/928/cancel',
- method: 'post',
- },
- },
- },
- {
- id: 926,
- name: 'production',
- started: false,
- build_path: '/twitter/flight/-/jobs/926',
- retry_path: '/twitter/flight/-/jobs/926/retry',
- play_path: '/twitter/flight/-/jobs/926/play',
- playable: true,
- created_at: '2018-04-04T20:00:57.202Z',
- updated_at: '2018-04-04T20:11:13.110Z',
- status: {
- icon: 'status_canceled',
- text: 'canceled',
- label: 'manual play action',
- group: 'canceled',
- tooltip: 'canceled',
- has_details: true,
- details_path: '/twitter/flight/-/jobs/926',
- favicon:
- '/assets/ci_favicons/dev/favicon_status_canceled-5491840b9b6feafba0bc599cbd49ee9580321dc809683856cf1b0d51532b1af6.ico',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/twitter/flight/-/jobs/926/play',
- method: 'post',
- },
- },
- },
- {
- id: 217,
- name: 'staging',
- started: '2018-03-07T08:41:46.234Z',
- build_path: '/twitter/flight/-/jobs/217',
- retry_path: '/twitter/flight/-/jobs/217/retry',
- playable: false,
- created_at: '2018-03-07T14:41:58.093Z',
- updated_at: '2018-03-07T14:41:58.093Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/twitter/flight/-/jobs/217',
- favicon:
- '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/twitter/flight/-/jobs/217/retry',
- method: 'post',
- },
- },
- },
- ],
- status: {
- icon: 'status_running',
- text: 'running',
- label: 'running',
- group: 'running',
- tooltip: 'running',
- has_details: true,
- details_path: '/twitter/flight/pipelines/13#deploy',
- favicon:
- '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico',
- },
- path: '/twitter/flight/pipelines/13#deploy',
- dropdown_path: '/twitter/flight/pipelines/13/stage.json?stage=deploy',
-};
diff --git a/spec/javascripts/pipelines/pipeline_details_mediator_spec.js b/spec/javascripts/pipelines/pipeline_details_mediator_spec.js
deleted file mode 100644
index 61ee2dc13ca..00000000000
--- a/spec/javascripts/pipelines/pipeline_details_mediator_spec.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
-import PipelineMediator from '~/pipelines/pipeline_details_mediator';
-
-describe('PipelineMdediator', () => {
- let mediator;
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mediator = new PipelineMediator({ endpoint: 'foo.json' });
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('should set defaults', () => {
- expect(mediator.options).toEqual({ endpoint: 'foo.json' });
- expect(mediator.state.isLoading).toEqual(false);
- expect(mediator.store).toBeDefined();
- expect(mediator.service).toBeDefined();
- });
-
- describe('request and store data', () => {
- it('should store received data', done => {
- mock.onGet('foo.json').reply(200, { id: '121123' });
- mediator.fetchPipeline();
-
- setTimeout(() => {
- expect(mediator.store.state.pipeline).toEqual({ id: '121123' });
- done();
- }, 0);
- });
- });
-});
diff --git a/spec/javascripts/pipelines/pipelines_actions_spec.js b/spec/javascripts/pipelines/pipelines_actions_spec.js
deleted file mode 100644
index 91f7d2167cc..00000000000
--- a/spec/javascripts/pipelines/pipelines_actions_spec.js
+++ /dev/null
@@ -1,128 +0,0 @@
-import Vue from 'vue';
-import MockAdapter from 'axios-mock-adapter';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { TEST_HOST } from 'spec/test_constants';
-import axios from '~/lib/utils/axios_utils';
-import PipelinesActions from '~/pipelines/components/pipelines_actions.vue';
-
-describe('Pipelines Actions dropdown', () => {
- const Component = Vue.extend(PipelinesActions);
- let vm;
- let mock;
-
- afterEach(() => {
- vm.$destroy();
- mock.restore();
- });
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- describe('manual actions', () => {
- const actions = [
- {
- name: 'stop_review',
- path: `${TEST_HOST}/root/review-app/builds/1893/play`,
- },
- {
- name: 'foo',
- path: `${TEST_HOST}/disabled/pipeline/action`,
- playable: false,
- },
- ];
-
- beforeEach(() => {
- vm = mountComponent(Component, { actions });
- });
-
- it('renders a dropdown with the provided actions', () => {
- const dropdownItems = vm.$el.querySelectorAll('.dropdown-menu li');
-
- expect(dropdownItems.length).toEqual(actions.length);
- });
-
- it("renders a disabled action when it's not playable", () => {
- const dropdownItem = vm.$el.querySelector('.dropdown-menu li:last-child button');
-
- expect(dropdownItem).toBeDisabled();
- });
-
- describe('on click', () => {
- it('makes a request and toggles the loading state', done => {
- mock.onPost(actions.path).reply(200);
-
- vm.$el.querySelector('.dropdown-menu li button').click();
-
- expect(vm.isLoading).toEqual(true);
-
- setTimeout(() => {
- expect(vm.isLoading).toEqual(false);
-
- done();
- });
- });
- });
- });
-
- describe('scheduled jobs', () => {
- const scheduledJobAction = {
- name: 'scheduled action',
- path: `${TEST_HOST}/scheduled/job/action`,
- playable: true,
- scheduled_at: '2063-04-05T00:42:00Z',
- };
- const expiredJobAction = {
- name: 'expired action',
- path: `${TEST_HOST}/expired/job/action`,
- playable: true,
- scheduled_at: '2018-10-05T08:23:00Z',
- };
- const findDropdownItem = action => {
- const buttons = vm.$el.querySelectorAll('.dropdown-menu li button');
- return Array.prototype.find.call(buttons, element =>
- element.innerText.trim().startsWith(action.name),
- );
- };
-
- beforeEach(done => {
- spyOn(Date, 'now').and.callFake(() => new Date('2063-04-04T00:42:00Z').getTime());
- vm = mountComponent(Component, { actions: [scheduledJobAction, expiredJobAction] });
-
- Vue.nextTick()
- .then(done)
- .catch(done.fail);
- });
-
- it('makes post request after confirming', done => {
- mock.onPost(scheduledJobAction.path).reply(200);
- spyOn(window, 'confirm').and.callFake(() => true);
-
- findDropdownItem(scheduledJobAction).click();
-
- expect(window.confirm).toHaveBeenCalled();
- setTimeout(() => {
- expect(mock.history.post.length).toBe(1);
- done();
- });
- });
-
- it('does not make post request if confirmation is cancelled', () => {
- mock.onPost(scheduledJobAction.path).reply(200);
- spyOn(window, 'confirm').and.callFake(() => false);
-
- findDropdownItem(scheduledJobAction).click();
-
- expect(window.confirm).toHaveBeenCalled();
- expect(mock.history.post.length).toBe(0);
- });
-
- it('displays the remaining time in the dropdown', () => {
- expect(findDropdownItem(scheduledJobAction)).toContainText('24:00:00');
- });
-
- it('displays 00:00:00 for expired jobs in the dropdown', () => {
- expect(findDropdownItem(expiredJobAction)).toContainText('00:00:00');
- });
- });
-});
diff --git a/spec/javascripts/pipelines/pipelines_artifacts_spec.js b/spec/javascripts/pipelines/pipelines_artifacts_spec.js
deleted file mode 100644
index 7705d5a19bf..00000000000
--- a/spec/javascripts/pipelines/pipelines_artifacts_spec.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Vue from 'vue';
-import artifactsComp from '~/pipelines/components/pipelines_artifacts.vue';
-
-describe('Pipelines Artifacts dropdown', () => {
- let component;
- let artifacts;
-
- beforeEach(() => {
- const ArtifactsComponent = Vue.extend(artifactsComp);
-
- artifacts = [
- {
- name: 'artifact',
- path: '/download/path',
- },
- ];
-
- component = new ArtifactsComponent({
- propsData: {
- artifacts,
- },
- }).$mount();
- });
-
- it('should render a dropdown with the provided artifacts', () => {
- expect(component.$el.querySelectorAll('.dropdown-menu li').length).toEqual(artifacts.length);
- });
-
- it('should render a link with the provided path', () => {
- expect(component.$el.querySelector('.dropdown-menu li a').getAttribute('href')).toEqual(
- artifacts[0].path,
- );
-
- expect(component.$el.querySelector('.dropdown-menu li a').textContent).toContain(
- artifacts[0].name,
- );
- });
-});
diff --git a/spec/javascripts/pipelines/pipelines_spec.js b/spec/javascripts/pipelines/pipelines_spec.js
deleted file mode 100644
index 5cd91413c5f..00000000000
--- a/spec/javascripts/pipelines/pipelines_spec.js
+++ /dev/null
@@ -1,783 +0,0 @@
-import Vue from 'vue';
-import MockAdapter from 'axios-mock-adapter';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import axios from '~/lib/utils/axios_utils';
-import pipelinesComp from '~/pipelines/components/pipelines.vue';
-import Store from '~/pipelines/stores/pipelines_store';
-import { pipelineWithStages, stageReply } from './mock_data';
-
-describe('Pipelines', () => {
- const jsonFixtureName = 'pipelines/pipelines.json';
-
- preloadFixtures(jsonFixtureName);
-
- let PipelinesComponent;
- let pipelines;
- let vm;
- let mock;
-
- const paths = {
- endpoint: 'twitter/flight/pipelines.json',
- autoDevopsPath: '/help/topics/autodevops/index.md',
- helpPagePath: '/help/ci/quick_start/README',
- emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg',
- errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
- noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
- ciLintPath: '/ci/lint',
- resetCachePath: '/twitter/flight/settings/ci_cd/reset_cache',
- newPipelinePath: '/twitter/flight/pipelines/new',
- };
-
- const noPermissions = {
- endpoint: 'twitter/flight/pipelines.json',
- autoDevopsPath: '/help/topics/autodevops/index.md',
- helpPagePath: '/help/ci/quick_start/README',
- emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg',
- errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
- noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
- };
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
-
- pipelines = getJSONFixture(jsonFixtureName);
-
- PipelinesComponent = Vue.extend(pipelinesComp);
- });
-
- afterEach(() => {
- vm.$destroy();
- mock.restore();
- });
-
- describe('With permission', () => {
- describe('With pipelines in main tab', () => {
- beforeEach(done => {
- mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
-
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: true,
- canCreatePipeline: true,
- ...paths,
- });
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders tabs', () => {
- expect(vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim()).toContain('All');
- });
-
- it('renders Run Pipeline link', () => {
- expect(vm.$el.querySelector('.js-run-pipeline').getAttribute('href')).toEqual(
- paths.newPipelinePath,
- );
- });
-
- it('renders CI Lint link', () => {
- expect(vm.$el.querySelector('.js-ci-lint').getAttribute('href')).toEqual(paths.ciLintPath);
- });
-
- it('renders Clear Runner Cache button', () => {
- expect(vm.$el.querySelector('.js-clear-cache').textContent.trim()).toEqual(
- 'Clear Runner Caches',
- );
- });
-
- it('renders pipelines table', () => {
- expect(vm.$el.querySelectorAll('.gl-responsive-table-row').length).toEqual(
- pipelines.pipelines.length + 1,
- );
- });
- });
-
- describe('Without pipelines on main tab with CI', () => {
- beforeEach(done => {
- mock.onGet('twitter/flight/pipelines.json').reply(200, {
- pipelines: [],
- count: {
- all: 0,
- pending: 0,
- running: 0,
- finished: 0,
- },
- });
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: true,
- canCreatePipeline: true,
- ...paths,
- });
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders tabs', () => {
- expect(vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim()).toContain('All');
- });
-
- it('renders Run Pipeline link', () => {
- expect(vm.$el.querySelector('.js-run-pipeline').getAttribute('href')).toEqual(
- paths.newPipelinePath,
- );
- });
-
- it('renders CI Lint link', () => {
- expect(vm.$el.querySelector('.js-ci-lint').getAttribute('href')).toEqual(paths.ciLintPath);
- });
-
- it('renders Clear Runner Cache button', () => {
- expect(vm.$el.querySelector('.js-clear-cache').textContent.trim()).toEqual(
- 'Clear Runner Caches',
- );
- });
-
- it('renders tab empty state', () => {
- expect(vm.$el.querySelector('.empty-state h4').textContent.trim()).toEqual(
- 'There are currently no pipelines.',
- );
- });
- });
-
- describe('Without pipelines nor CI', () => {
- beforeEach(done => {
- mock.onGet('twitter/flight/pipelines.json').reply(200, {
- pipelines: [],
- count: {
- all: 0,
- pending: 0,
- running: 0,
- finished: 0,
- },
- });
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: false,
- canCreatePipeline: true,
- ...paths,
- });
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders empty state', () => {
- expect(vm.$el.querySelector('.js-empty-state h4').textContent.trim()).toEqual(
- 'Build with confidence',
- );
-
- expect(vm.$el.querySelector('.js-get-started-pipelines').getAttribute('href')).toEqual(
- paths.helpPagePath,
- );
- });
-
- it('does not render tabs nor buttons', () => {
- expect(vm.$el.querySelector('.js-pipelines-tab-all')).toBeNull();
- expect(vm.$el.querySelector('.js-run-pipeline')).toBeNull();
- expect(vm.$el.querySelector('.js-ci-lint')).toBeNull();
- expect(vm.$el.querySelector('.js-clear-cache')).toBeNull();
- });
- });
-
- describe('When API returns error', () => {
- beforeEach(done => {
- mock.onGet('twitter/flight/pipelines.json').reply(500, {});
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: false,
- canCreatePipeline: true,
- ...paths,
- });
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders tabs', () => {
- expect(vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim()).toContain('All');
- });
-
- it('renders buttons', () => {
- expect(vm.$el.querySelector('.js-run-pipeline').getAttribute('href')).toEqual(
- paths.newPipelinePath,
- );
-
- expect(vm.$el.querySelector('.js-ci-lint').getAttribute('href')).toEqual(paths.ciLintPath);
- expect(vm.$el.querySelector('.js-clear-cache').textContent.trim()).toEqual(
- 'Clear Runner Caches',
- );
- });
-
- it('renders error state', () => {
- expect(vm.$el.querySelector('.empty-state').textContent.trim()).toContain(
- 'There was an error fetching the pipelines.',
- );
- });
- });
- });
-
- describe('Without permission', () => {
- describe('With pipelines in main tab', () => {
- beforeEach(done => {
- mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
-
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: false,
- canCreatePipeline: false,
- ...noPermissions,
- });
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders tabs', () => {
- expect(vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim()).toContain('All');
- });
-
- it('does not render buttons', () => {
- expect(vm.$el.querySelector('.js-run-pipeline')).toBeNull();
- expect(vm.$el.querySelector('.js-ci-lint')).toBeNull();
- expect(vm.$el.querySelector('.js-clear-cache')).toBeNull();
- });
-
- it('renders pipelines table', () => {
- expect(vm.$el.querySelectorAll('.gl-responsive-table-row').length).toEqual(
- pipelines.pipelines.length + 1,
- );
- });
- });
-
- describe('Without pipelines on main tab with CI', () => {
- beforeEach(done => {
- mock.onGet('twitter/flight/pipelines.json').reply(200, {
- pipelines: [],
- count: {
- all: 0,
- pending: 0,
- running: 0,
- finished: 0,
- },
- });
-
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: true,
- canCreatePipeline: false,
- ...noPermissions,
- });
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders tabs', () => {
- expect(vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim()).toContain('All');
- });
-
- it('does not render buttons', () => {
- expect(vm.$el.querySelector('.js-run-pipeline')).toBeNull();
- expect(vm.$el.querySelector('.js-ci-lint')).toBeNull();
- expect(vm.$el.querySelector('.js-clear-cache')).toBeNull();
- });
-
- it('renders tab empty state', () => {
- expect(vm.$el.querySelector('.empty-state h4').textContent.trim()).toEqual(
- 'There are currently no pipelines.',
- );
- });
- });
-
- describe('Without pipelines nor CI', () => {
- beforeEach(done => {
- mock.onGet('twitter/flight/pipelines.json').reply(200, {
- pipelines: [],
- count: {
- all: 0,
- pending: 0,
- running: 0,
- finished: 0,
- },
- });
-
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: false,
- canCreatePipeline: false,
- ...noPermissions,
- });
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders empty state without button to set CI', () => {
- expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toEqual(
- 'This project is not currently set up to run pipelines.',
- );
-
- expect(vm.$el.querySelector('.js-get-started-pipelines')).toBeNull();
- });
-
- it('does not render tabs or buttons', () => {
- expect(vm.$el.querySelector('.js-pipelines-tab-all')).toBeNull();
- expect(vm.$el.querySelector('.js-run-pipeline')).toBeNull();
- expect(vm.$el.querySelector('.js-ci-lint')).toBeNull();
- expect(vm.$el.querySelector('.js-clear-cache')).toBeNull();
- });
- });
-
- describe('When API returns error', () => {
- beforeEach(done => {
- mock.onGet('twitter/flight/pipelines.json').reply(500, {});
-
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: false,
- canCreatePipeline: true,
- ...noPermissions,
- });
-
- setTimeout(() => {
- done();
- });
- });
-
- it('renders tabs', () => {
- expect(vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim()).toContain('All');
- });
-
- it('does not renders buttons', () => {
- expect(vm.$el.querySelector('.js-run-pipeline')).toBeNull();
- expect(vm.$el.querySelector('.js-ci-lint')).toBeNull();
- expect(vm.$el.querySelector('.js-clear-cache')).toBeNull();
- });
-
- it('renders error state', () => {
- expect(vm.$el.querySelector('.empty-state').textContent.trim()).toContain(
- 'There was an error fetching the pipelines.',
- );
- });
- });
- });
-
- describe('successful request', () => {
- describe('with pipelines', () => {
- beforeEach(() => {
- mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
-
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: true,
- canCreatePipeline: true,
- ...paths,
- });
- });
-
- it('should render table', done => {
- setTimeout(() => {
- expect(vm.$el.querySelector('.table-holder')).toBeDefined();
- expect(vm.$el.querySelectorAll('.gl-responsive-table-row').length).toEqual(
- pipelines.pipelines.length + 1,
- );
- done();
- });
- });
-
- it('should render navigation tabs', done => {
- setTimeout(() => {
- expect(vm.$el.querySelector('.js-pipelines-tab-pending').textContent.trim()).toContain(
- 'Pending',
- );
-
- expect(vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim()).toContain('All');
-
- expect(vm.$el.querySelector('.js-pipelines-tab-running').textContent.trim()).toContain(
- 'Running',
- );
-
- expect(vm.$el.querySelector('.js-pipelines-tab-finished').textContent.trim()).toContain(
- 'Finished',
- );
-
- expect(vm.$el.querySelector('.js-pipelines-tab-branches').textContent.trim()).toContain(
- 'Branches',
- );
-
- expect(vm.$el.querySelector('.js-pipelines-tab-tags').textContent.trim()).toContain(
- 'Tags',
- );
- done();
- });
- });
-
- it('should make an API request when using tabs', done => {
- setTimeout(() => {
- spyOn(vm, 'updateContent');
- vm.$el.querySelector('.js-pipelines-tab-finished').click();
-
- expect(vm.updateContent).toHaveBeenCalledWith({ scope: 'finished', page: '1' });
- done();
- });
- });
-
- describe('with pagination', () => {
- it('should make an API request when using pagination', done => {
- setTimeout(() => {
- spyOn(vm, 'updateContent');
- // Mock pagination
- vm.store.state.pageInfo = {
- page: 1,
- total: 10,
- perPage: 2,
- nextPage: 2,
- totalPages: 5,
- };
-
- vm.$nextTick(() => {
- vm.$el.querySelector('.next-page-item').click();
-
- expect(vm.updateContent).toHaveBeenCalledWith({ scope: 'all', page: '2' });
-
- done();
- });
- });
- });
- });
- });
- });
-
- describe('methods', () => {
- beforeEach(() => {
- spyOn(window.history, 'pushState').and.stub();
- });
-
- describe('updateContent', () => {
- it('should set given parameters', () => {
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: true,
- canCreatePipeline: true,
- ...paths,
- });
- vm.updateContent({ scope: 'finished', page: '4' });
-
- expect(vm.page).toEqual('4');
- expect(vm.scope).toEqual('finished');
- expect(vm.requestData.scope).toEqual('finished');
- expect(vm.requestData.page).toEqual('4');
- });
- });
-
- describe('onChangeTab', () => {
- it('should set page to 1', () => {
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: true,
- canCreatePipeline: true,
- ...paths,
- });
- spyOn(vm, 'updateContent');
-
- vm.onChangeTab('running');
-
- expect(vm.updateContent).toHaveBeenCalledWith({ scope: 'running', page: '1' });
- });
- });
-
- describe('onChangePage', () => {
- it('should update page and keep scope', () => {
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: true,
- canCreatePipeline: true,
- ...paths,
- });
- spyOn(vm, 'updateContent');
-
- vm.onChangePage(4);
-
- expect(vm.updateContent).toHaveBeenCalledWith({ scope: vm.scope, page: '4' });
- });
- });
- });
-
- describe('computed properties', () => {
- beforeEach(() => {
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: true,
- canCreatePipeline: true,
- ...paths,
- });
- });
-
- describe('tabs', () => {
- it('returns default tabs', () => {
- expect(vm.tabs).toEqual([
- { name: 'All', scope: 'all', count: undefined, isActive: true },
- { name: 'Pending', scope: 'pending', count: undefined, isActive: false },
- { name: 'Running', scope: 'running', count: undefined, isActive: false },
- { name: 'Finished', scope: 'finished', count: undefined, isActive: false },
- { name: 'Branches', scope: 'branches', isActive: false },
- { name: 'Tags', scope: 'tags', isActive: false },
- ]);
- });
- });
-
- describe('emptyTabMessage', () => {
- it('returns message with scope', done => {
- vm.scope = 'pending';
-
- vm.$nextTick(() => {
- expect(vm.emptyTabMessage).toEqual('There are currently no pending pipelines.');
- done();
- });
- });
-
- it('returns message without scope when scope is `all`', () => {
- expect(vm.emptyTabMessage).toEqual('There are currently no pipelines.');
- });
- });
-
- describe('stateToRender', () => {
- it('returns loading state when the app is loading', () => {
- expect(vm.stateToRender).toEqual('loading');
- });
-
- it('returns error state when app has error', done => {
- vm.hasError = true;
- vm.isLoading = false;
-
- vm.$nextTick(() => {
- expect(vm.stateToRender).toEqual('error');
- done();
- });
- });
-
- it('returns table list when app has pipelines', done => {
- vm.isLoading = false;
- vm.hasError = false;
- vm.state.pipelines = pipelines.pipelines;
-
- vm.$nextTick(() => {
- expect(vm.stateToRender).toEqual('tableList');
-
- done();
- });
- });
-
- it('returns empty tab when app does not have pipelines but project has pipelines', done => {
- vm.state.count.all = 10;
- vm.isLoading = false;
-
- vm.$nextTick(() => {
- expect(vm.stateToRender).toEqual('emptyTab');
-
- done();
- });
- });
-
- it('returns empty tab when project has CI', done => {
- vm.isLoading = false;
- vm.$nextTick(() => {
- expect(vm.stateToRender).toEqual('emptyTab');
-
- done();
- });
- });
-
- it('returns empty state when project does not have pipelines nor CI', done => {
- vm.isLoading = false;
- vm.hasGitlabCi = false;
- vm.$nextTick(() => {
- expect(vm.stateToRender).toEqual('emptyState');
-
- done();
- });
- });
- });
-
- describe('shouldRenderTabs', () => {
- it('returns true when state is loading & has already made the first request', done => {
- vm.isLoading = true;
- vm.hasMadeRequest = true;
-
- vm.$nextTick(() => {
- expect(vm.shouldRenderTabs).toEqual(true);
-
- done();
- });
- });
-
- it('returns true when state is tableList & has already made the first request', done => {
- vm.isLoading = false;
- vm.state.pipelines = pipelines.pipelines;
- vm.hasMadeRequest = true;
-
- vm.$nextTick(() => {
- expect(vm.shouldRenderTabs).toEqual(true);
-
- done();
- });
- });
-
- it('returns true when state is error & has already made the first request', done => {
- vm.isLoading = false;
- vm.hasError = true;
- vm.hasMadeRequest = true;
-
- vm.$nextTick(() => {
- expect(vm.shouldRenderTabs).toEqual(true);
-
- done();
- });
- });
-
- it('returns true when state is empty tab & has already made the first request', done => {
- vm.isLoading = false;
- vm.state.count.all = 10;
- vm.hasMadeRequest = true;
-
- vm.$nextTick(() => {
- expect(vm.shouldRenderTabs).toEqual(true);
-
- done();
- });
- });
-
- it('returns false when has not made first request', done => {
- vm.hasMadeRequest = false;
-
- vm.$nextTick(() => {
- expect(vm.shouldRenderTabs).toEqual(false);
-
- done();
- });
- });
-
- it('returns false when state is empty state', done => {
- vm.isLoading = false;
- vm.hasMadeRequest = true;
- vm.hasGitlabCi = false;
-
- vm.$nextTick(() => {
- expect(vm.shouldRenderTabs).toEqual(false);
-
- done();
- });
- });
- });
-
- describe('shouldRenderButtons', () => {
- it('returns true when it has paths & has made the first request', done => {
- vm.hasMadeRequest = true;
-
- vm.$nextTick(() => {
- expect(vm.shouldRenderButtons).toEqual(true);
-
- done();
- });
- });
-
- it('returns false when it has not made the first request', done => {
- vm.hasMadeRequest = false;
-
- vm.$nextTick(() => {
- expect(vm.shouldRenderButtons).toEqual(false);
-
- done();
- });
- });
- });
- });
-
- describe('updates results when a staged is clicked', () => {
- beforeEach(() => {
- const copyPipeline = Object.assign({}, pipelineWithStages);
- copyPipeline.id += 1;
- mock
- .onGet('twitter/flight/pipelines.json')
- .reply(
- 200,
- {
- pipelines: [pipelineWithStages],
- count: {
- all: 1,
- finished: 1,
- pending: 0,
- running: 0,
- },
- },
- {
- 'POLL-INTERVAL': 100,
- },
- )
- .onGet(pipelineWithStages.details.stages[0].dropdown_path)
- .reply(200, stageReply);
-
- vm = mountComponent(PipelinesComponent, {
- store: new Store(),
- hasGitlabCi: true,
- canCreatePipeline: true,
- ...paths,
- });
- });
-
- describe('when a request is being made', () => {
- it('stops polling, cancels the request, & restarts polling', done => {
- spyOn(vm.poll, 'stop');
- spyOn(vm.poll, 'restart');
- spyOn(vm.service.cancelationSource, 'cancel').and.callThrough();
-
- setTimeout(() => {
- vm.isMakingRequest = true;
- return vm
- .$nextTick()
- .then(() => {
- vm.$el.querySelector('.js-builds-dropdown-button').click();
- })
- .then(() => {
- expect(vm.service.cancelationSource.cancel).toHaveBeenCalled();
- expect(vm.poll.stop).toHaveBeenCalled();
-
- setTimeout(() => {
- expect(vm.poll.restart).toHaveBeenCalled();
- done();
- }, 0);
- })
- .catch(done.fail);
- }, 0);
- });
- });
-
- describe('when no request is being made', () => {
- it('stops polling & restarts polling', done => {
- spyOn(vm.poll, 'stop');
- spyOn(vm.poll, 'restart');
-
- setTimeout(() => {
- vm.$el.querySelector('.js-builds-dropdown-button').click();
-
- expect(vm.poll.stop).toHaveBeenCalled();
-
- setTimeout(() => {
- expect(vm.poll.restart).toHaveBeenCalled();
- done();
- }, 0);
- }, 0);
- });
- });
- });
-});
diff --git a/spec/javascripts/pipelines/pipelines_table_spec.js b/spec/javascripts/pipelines/pipelines_table_spec.js
deleted file mode 100644
index 5c3387190ab..00000000000
--- a/spec/javascripts/pipelines/pipelines_table_spec.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import Vue from 'vue';
-import pipelinesTableComp from '~/pipelines/components/pipelines_table.vue';
-import '~/lib/utils/datetime_utility';
-
-describe('Pipelines Table', () => {
- const jsonFixtureName = 'pipelines/pipelines.json';
-
- let pipeline;
- let PipelinesTableComponent;
-
- preloadFixtures(jsonFixtureName);
-
- beforeEach(() => {
- const { pipelines } = getJSONFixture(jsonFixtureName);
-
- PipelinesTableComponent = Vue.extend(pipelinesTableComp);
- pipeline = pipelines.find(p => p.user !== null && p.commit !== null);
- });
-
- describe('table', () => {
- let component;
- beforeEach(() => {
- component = new PipelinesTableComponent({
- propsData: {
- pipelines: [],
- autoDevopsHelpPath: 'foo',
- viewType: 'root',
- },
- }).$mount();
- });
-
- afterEach(() => {
- component.$destroy();
- });
-
- it('should render a table', () => {
- expect(component.$el.getAttribute('class')).toContain('ci-table');
- });
-
- it('should render table head with correct columns', () => {
- expect(
- component.$el.querySelector('.table-section.js-pipeline-status').textContent.trim(),
- ).toEqual('Status');
-
- expect(
- component.$el.querySelector('.table-section.js-pipeline-info').textContent.trim(),
- ).toEqual('Pipeline');
-
- expect(
- component.$el.querySelector('.table-section.js-pipeline-commit').textContent.trim(),
- ).toEqual('Commit');
-
- expect(
- component.$el.querySelector('.table-section.js-pipeline-stages').textContent.trim(),
- ).toEqual('Stages');
- });
- });
-
- describe('without data', () => {
- it('should render an empty table', () => {
- const component = new PipelinesTableComponent({
- propsData: {
- pipelines: [],
- autoDevopsHelpPath: 'foo',
- viewType: 'root',
- },
- }).$mount();
-
- expect(component.$el.querySelectorAll('.commit.gl-responsive-table-row').length).toEqual(0);
- });
- });
-
- describe('with data', () => {
- it('should render rows', () => {
- const component = new PipelinesTableComponent({
- propsData: {
- pipelines: [pipeline],
- autoDevopsHelpPath: 'foo',
- viewType: 'root',
- },
- }).$mount();
-
- expect(component.$el.querySelectorAll('.commit.gl-responsive-table-row').length).toEqual(1);
- });
- });
-});
diff --git a/spec/javascripts/pipelines/stage_spec.js b/spec/javascripts/pipelines/stage_spec.js
deleted file mode 100644
index b99688ec371..00000000000
--- a/spec/javascripts/pipelines/stage_spec.js
+++ /dev/null
@@ -1,136 +0,0 @@
-import Vue from 'vue';
-import MockAdapter from 'axios-mock-adapter';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import axios from '~/lib/utils/axios_utils';
-import stage from '~/pipelines/components/stage.vue';
-import eventHub from '~/pipelines/event_hub';
-import { stageReply } from './mock_data';
-
-describe('Pipelines stage component', () => {
- let StageComponent;
- let component;
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
-
- StageComponent = Vue.extend(stage);
-
- component = mountComponent(StageComponent, {
- stage: {
- status: {
- group: 'success',
- icon: 'status_success',
- title: 'success',
- },
- dropdown_path: 'path.json',
- },
- updateDropdown: false,
- });
- });
-
- afterEach(() => {
- component.$destroy();
- mock.restore();
- });
-
- it('should render a dropdown with the status icon', () => {
- expect(component.$el.getAttribute('class')).toEqual('dropdown');
- expect(component.$el.querySelector('svg')).toBeDefined();
- expect(component.$el.querySelector('button').getAttribute('data-toggle')).toEqual('dropdown');
- });
-
- describe('with successful request', () => {
- beforeEach(() => {
- mock.onGet('path.json').reply(200, stageReply);
- });
-
- it('should render the received data and emit `clickedDropdown` event', done => {
- spyOn(eventHub, '$emit');
- component.$el.querySelector('button').click();
-
- setTimeout(() => {
- expect(
- component.$el.querySelector('.js-builds-dropdown-container ul').textContent.trim(),
- ).toContain(stageReply.latest_statuses[0].name);
-
- expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown');
- done();
- }, 0);
- });
- });
-
- describe('when request fails', () => {
- beforeEach(() => {
- mock.onGet('path.json').reply(500);
- });
-
- it('should close the dropdown', () => {
- component.$el.click();
-
- setTimeout(() => {
- expect(component.$el.classList.contains('open')).toEqual(false);
- }, 0);
- });
- });
-
- describe('update endpoint correctly', () => {
- beforeEach(() => {
- const copyStage = Object.assign({}, stageReply);
- copyStage.latest_statuses[0].name = 'this is the updated content';
- mock.onGet('bar.json').reply(200, copyStage);
- });
-
- it('should update the stage to request the new endpoint provided', done => {
- component.stage = {
- status: {
- group: 'running',
- icon: 'status_running',
- title: 'running',
- },
- dropdown_path: 'bar.json',
- };
-
- Vue.nextTick(() => {
- component.$el.querySelector('button').click();
-
- setTimeout(() => {
- expect(
- component.$el.querySelector('.js-builds-dropdown-container ul').textContent.trim(),
- ).toContain('this is the updated content');
- done();
- });
- });
- });
- });
-
- describe('pipelineActionRequestComplete', () => {
- beforeEach(() => {
- mock.onGet('path.json').reply(200, stageReply);
-
- mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200);
- });
-
- describe('within pipeline table', () => {
- it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', done => {
- spyOn(eventHub, '$emit');
-
- component.type = 'PIPELINES_TABLE';
- component.$el.querySelector('button').click();
-
- setTimeout(() => {
- component.$el.querySelector('.js-ci-action').click();
- setTimeout(() => {
- component
- .$nextTick()
- .then(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable');
- })
- .then(done)
- .catch(done.fail);
- }, 0);
- }, 0);
- });
- });
- });
-});
diff --git a/spec/javascripts/pipelines/stores/pipeline.json b/spec/javascripts/pipelines/stores/pipeline.json
deleted file mode 100644
index 7d5891d3d52..00000000000
--- a/spec/javascripts/pipelines/stores/pipeline.json
+++ /dev/null
@@ -1,167 +0,0 @@
-{
- "id": 37232567,
- "user": {
- "id": 113870,
- "name": "Phil Hughes",
- "username": "iamphill",
- "state": "active",
- "avatar_url": "https://secure.gravatar.com/avatar/533a51534470a11062df393543eab649?s=80\u0026d=identicon",
- "web_url": "https://gitlab.com/iamphill",
- "status_tooltip_html": null,
- "path": "/iamphill"
- },
- "active": false,
- "coverage": null,
- "source": "push",
- "created_at": "2018-11-20T10:22:52.617Z",
- "updated_at": "2018-11-20T10:24:09.511Z",
- "path": "/gitlab-org/gl-vue-cli/pipelines/37232567",
- "flags": {
- "latest": true,
- "stuck": false,
- "auto_devops": false,
- "yaml_errors": false,
- "retryable": false,
- "cancelable": false,
- "failure_reason": false
- },
- "details": {
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gl-vue-cli/pipelines/37232567",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "duration": 65,
- "finished_at": "2018-11-20T10:24:09.483Z",
- "stages": [
- {
- "name": "test",
- "title": "test: passed",
- "groups": [
- {
- "name": "eslint",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gl-vue-cli/-/jobs/122845352",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-org/gl-vue-cli/-/jobs/122845352/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 122845352,
- "name": "eslint",
- "started": "2018-11-20T10:22:53.369Z",
- "archived": false,
- "build_path": "/gitlab-org/gl-vue-cli/-/jobs/122845352",
- "retry_path": "/gitlab-org/gl-vue-cli/-/jobs/122845352/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-11-20T10:22:52.630Z",
- "updated_at": "2018-11-20T10:23:58.948Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gl-vue-cli/-/jobs/122845352",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-org/gl-vue-cli/-/jobs/122845352/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gl-vue-cli/pipelines/37232567#test",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/gitlab-org/gl-vue-cli/pipelines/37232567#test",
- "dropdown_path": "/gitlab-org/gl-vue-cli/pipelines/37232567/stage.json?stage=test"
- }
- ],
- "artifacts": [],
- "manual_actions": [],
- "scheduled_actions": []
- },
- "ref": {
- "name": "master",
- "path": "/gitlab-org/gl-vue-cli/commits/master",
- "tag": false,
- "branch": true
- },
- "commit": {
- "id": "8f179601d481950bcb67032caeb33d1c24dde6bd",
- "short_id": "8f179601",
- "title": "Merge branch 'gl-cli-startt' into 'master'",
- "created_at": "2018-11-20T10:22:51.000Z",
- "parent_ids": [
- "781d78fcd3d6c17ccf208f0cf0ab47c3e5397118",
- "d227a0bb858c48eeee7393fcade1a33748f35183"
- ],
- "message": "Merge branch 'gl-cli-startt' into 'master'\n\nFirst iteration of the CLI\n\nCloses gitlab-foss#53657\n\nSee merge request gitlab-org/gl-vue-cli!2",
- "author_name": "Phil Hughes",
- "author_email": "me@iamphill.com",
- "authored_date": "2018-11-20T10:22:51.000Z",
- "committer_name": "Phil Hughes",
- "committer_email": "me@iamphill.com",
- "committed_date": "2018-11-20T10:22:51.000Z",
- "author": {
- "id": 113870,
- "name": "Phil Hughes",
- "username": "iamphill",
- "state": "active",
- "avatar_url": "https://secure.gravatar.com/avatar/533a51534470a11062df393543eab649?s=80\u0026d=identicon",
- "web_url": "https://gitlab.com/iamphill",
- "status_tooltip_html": null,
- "path": "/iamphill"
- },
- "author_gravatar_url": "https://secure.gravatar.com/avatar/533a51534470a11062df393543eab649?s=80\u0026d=identicon",
- "commit_url": "https://gitlab.com/gitlab-org/gl-vue-cli/commit/8f179601d481950bcb67032caeb33d1c24dde6bd",
- "commit_path": "/gitlab-org/gl-vue-cli/commit/8f179601d481950bcb67032caeb33d1c24dde6bd"
- },
- "triggered_by": null,
- "triggered": []
-}
diff --git a/spec/javascripts/pipelines/stores/pipeline_store.js b/spec/javascripts/pipelines/stores/pipeline_store.js
deleted file mode 100644
index 4a0b3bf4c02..00000000000
--- a/spec/javascripts/pipelines/stores/pipeline_store.js
+++ /dev/null
@@ -1,165 +0,0 @@
-import PipelineStore from '~/pipelines/stores/pipeline_store';
-import LinkedPipelines from '../linked_pipelines_mock.json';
-
-describe('EE Pipeline store', () => {
- let store;
- let data;
-
- beforeEach(() => {
- store = new PipelineStore();
- data = Object.assign({}, LinkedPipelines);
- });
-
- describe('storePipeline', () => {
- beforeAll(() => {
- store.storePipeline(data);
- });
-
- describe('triggered_by', () => {
- it('sets triggered_by as an array', () => {
- expect(store.state.pipeline.triggered_by.length).toEqual(1);
- });
-
- it('adds isExpanding & isLoading keys set to false', () => {
- expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
- expect(store.state.pipeline.triggered_by[0].isLoading).toEqual(false);
- });
-
- it('parses nested triggered_by', () => {
- expect(store.state.pipeline.triggered_by[0].triggered_by.length).toEqual(1);
- expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false);
- expect(store.state.pipeline.triggered_by[0].triggered_by[0].isLoading).toEqual(false);
- });
- });
-
- describe('triggered', () => {
- it('adds isExpanding & isLoading keys set to false for each triggered pipeline', () => {
- store.state.pipeline.triggered.forEach(pipeline => {
- expect(pipeline.isExpanded).toEqual(false);
- expect(pipeline.isLoading).toEqual(false);
- });
- });
-
- it('parses nested triggered pipelines', () => {
- store.state.pipeline.triggered[1].triggered.forEach(pipeline => {
- expect(pipeline.isExpanded).toEqual(false);
- expect(pipeline.isLoading).toEqual(false);
- });
- });
- });
- });
-
- describe('resetTriggeredByPipeline', () => {
- beforeEach(() => {
- store.storePipeline(data);
- });
-
- it('closes the pipeline & nested ones', () => {
- store.state.pipeline.triggered_by[0].isExpanded = true;
- store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded = true;
-
- store.resetTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
-
- expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
- expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false);
- });
- });
-
- describe('openTriggeredByPipeline', () => {
- beforeEach(() => {
- store.storePipeline(data);
- });
-
- it('opens the given pipeline', () => {
- store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
-
- expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(true);
- });
- });
-
- describe('closeTriggeredByPipeline', () => {
- beforeEach(() => {
- store.storePipeline(data);
- });
-
- it('closes the given pipeline', () => {
- // open it first
- store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
-
- store.closeTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
-
- expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
- });
- });
-
- describe('resetTriggeredPipelines', () => {
- beforeEach(() => {
- store.storePipeline(data);
- });
-
- it('closes the pipeline & nested ones', () => {
- store.state.pipeline.triggered[0].isExpanded = true;
- store.state.pipeline.triggered[0].triggered[0].isExpanded = true;
-
- store.resetTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
-
- expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false);
- expect(store.state.pipeline.triggered[0].triggered[0].isExpanded).toEqual(false);
- });
- });
-
- describe('openTriggeredPipeline', () => {
- beforeEach(() => {
- store.storePipeline(data);
- });
-
- it('opens the given pipeline', () => {
- store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
-
- expect(store.state.pipeline.triggered[0].isExpanded).toEqual(true);
- });
- });
-
- describe('closeTriggeredPipeline', () => {
- beforeEach(() => {
- store.storePipeline(data);
- });
-
- it('closes the given pipeline', () => {
- // open it first
- store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
-
- store.closeTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
-
- expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false);
- });
- });
-
- describe('toggleLoading', () => {
- beforeEach(() => {
- store.storePipeline(data);
- });
-
- it('toggles the isLoading property for the given pipeline', () => {
- store.togglePipeline(store.state.pipeline.triggered[0]);
-
- expect(store.state.pipeline.triggered[0].isLoading).toEqual(true);
- });
- });
-
- describe('addExpandedPipelineToRequestData', () => {
- it('pushes the given id to expandedPipelines array', () => {
- store.addExpandedPipelineToRequestData('213231');
-
- expect(store.state.expandedPipelines).toEqual(['213231']);
- });
- });
-
- describe('removeExpandedPipelineToRequestData', () => {
- it('pushes the given id to expandedPipelines array', () => {
- store.removeExpandedPipelineToRequestData('213231');
-
- expect(store.state.expandedPipelines).toEqual([]);
- });
- });
-});
diff --git a/spec/javascripts/pipelines/stores/pipeline_with_triggered.json b/spec/javascripts/pipelines/stores/pipeline_with_triggered.json
deleted file mode 100644
index 1fa15e45792..00000000000
--- a/spec/javascripts/pipelines/stores/pipeline_with_triggered.json
+++ /dev/null
@@ -1,381 +0,0 @@
-{
- "id": 23211253,
- "user": {
- "id": 3585,
- "name": "Achilleas Pipinellis",
- "username": "axil",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png",
- "web_url": "https://gitlab.com/axil",
- "status_tooltip_html": "\u003cspan class=\"user-status-emoji has-tooltip\" title=\"\" data-html=\"true\" data-placement=\"top\"\u003e\u003cgl-emoji title=\"trumpet\" data-name=\"trumpet\" data-unicode-version=\"6.0\"\u003e🎺\u003c/gl-emoji\u003e\u003c/span\u003e",
- "path": "/axil"
- },
- "active": false,
- "coverage": null,
- "source": "push",
- "created_at": "2018-06-05T11:31:30.452Z",
- "updated_at": "2018-10-31T16:35:31.305Z",
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253",
- "flags": {
- "latest": false,
- "stuck": false,
- "auto_devops": false,
- "yaml_errors": false,
- "retryable": false,
- "cancelable": false,
- "failure_reason": false
- },
- "details": {
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "duration": 53,
- "finished_at": "2018-10-31T16:35:31.299Z",
- "stages": [
- {
- "name": "prebuild",
- "title": "prebuild: passed",
- "groups": [
- {
- "name": "review-docs-deploy",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "manual play action",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 72469032,
- "name": "review-docs-deploy",
- "started": "2018-10-31T16:34:58.778Z",
- "archived": false,
- "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
- "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/retry",
- "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-06-05T11:31:30.495Z",
- "updated_at": "2018-10-31T16:35:31.251Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "manual play action",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild",
- "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=prebuild"
- },
- {
- "name": "test",
- "title": "test: passed",
- "groups": [
- {
- "name": "docs check links",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 72469033,
- "name": "docs check links",
- "started": "2018-06-05T11:31:33.240Z",
- "archived": false,
- "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
- "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-06-05T11:31:30.627Z",
- "updated_at": "2018-06-05T11:31:54.363Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#test",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253#test",
- "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=test"
- },
- {
- "name": "cleanup",
- "title": "cleanup: skipped",
- "groups": [
- {
- "name": "review-docs-cleanup",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual stop action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "stop",
- "title": "Stop",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "method": "post",
- "button_title": "Stop this environment"
- }
- },
- "jobs": [
- {
- "id": 72469034,
- "name": "review-docs-cleanup",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
- "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-06-05T11:31:30.760Z",
- "updated_at": "2018-06-05T11:31:56.037Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual stop action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "stop",
- "title": "Stop",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "method": "post",
- "button_title": "Stop this environment"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup",
- "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=cleanup"
- }
- ],
- "artifacts": [],
- "manual_actions": [
- {
- "name": "review-docs-cleanup",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "review-docs-deploy",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "playable": true,
- "scheduled": false
- }
- ],
- "scheduled_actions": []
- },
- "ref": {
- "name": "docs/add-development-guide-to-readme",
- "path": "/gitlab-org/gitlab-runner/commits/docs/add-development-guide-to-readme",
- "tag": false,
- "branch": true
- },
- "commit": {
- "id": "8083eb0a920572214d0dccedd7981f05d535ad46",
- "short_id": "8083eb0a",
- "title": "Add link to development guide in readme",
- "created_at": "2018-06-05T11:30:48.000Z",
- "parent_ids": ["1d7cf79b5a1a2121b9474ac20d61c1b8f621289d"],
- "message": "Add link to development guide in readme\n\nCloses https://gitlab.com/gitlab-org/gitlab-runner/issues/3122\n",
- "author_name": "Achilleas Pipinellis",
- "author_email": "axil@gitlab.com",
- "authored_date": "2018-06-05T11:30:48.000Z",
- "committer_name": "Achilleas Pipinellis",
- "committer_email": "axil@gitlab.com",
- "committed_date": "2018-06-05T11:30:48.000Z",
- "author": {
- "id": 3585,
- "name": "Achilleas Pipinellis",
- "username": "axil",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png",
- "web_url": "https://gitlab.com/axil",
- "status_tooltip_html": null,
- "path": "/axil"
- },
- "author_gravatar_url": "https://secure.gravatar.com/avatar/1d37af00eec153a8333a4ce18e9aea41?s=80\u0026d=identicon",
- "commit_url": "https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46",
- "commit_path": "/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46"
- },
- "triggered_by": null,
- "triggered": [
- {
- "id": 34993051,
- "user": {
- "id": 376774,
- "name": "Alessio Caiazza",
- "username": "nolith",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
- "web_url": "https://gitlab.com/nolith",
- "status_tooltip_html": null,
- "path": "/nolith"
- },
- "active": false,
- "coverage": null,
- "source": "pipeline",
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "details": {
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
- }
- },
- "project": {
- "id": 1794617,
- "name": "GitLab Docs",
- "full_path": "/gitlab-com/gitlab-docs",
- "full_name": "GitLab.com / GitLab Docs"
- }
- }
- ]
-}
diff --git a/spec/javascripts/pipelines/stores/pipeline_with_triggered_by.json b/spec/javascripts/pipelines/stores/pipeline_with_triggered_by.json
deleted file mode 100644
index 7aeea6f3ebb..00000000000
--- a/spec/javascripts/pipelines/stores/pipeline_with_triggered_by.json
+++ /dev/null
@@ -1,379 +0,0 @@
-{
- "id": 23211253,
- "user": {
- "id": 3585,
- "name": "Achilleas Pipinellis",
- "username": "axil",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png",
- "web_url": "https://gitlab.com/axil",
- "status_tooltip_html": "\u003cspan class=\"user-status-emoji has-tooltip\" title=\"\" data-html=\"true\" data-placement=\"top\"\u003e\u003cgl-emoji title=\"trumpet\" data-name=\"trumpet\" data-unicode-version=\"6.0\"\u003e🎺\u003c/gl-emoji\u003e\u003c/span\u003e",
- "path": "/axil"
- },
- "active": false,
- "coverage": null,
- "source": "push",
- "created_at": "2018-06-05T11:31:30.452Z",
- "updated_at": "2018-10-31T16:35:31.305Z",
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253",
- "flags": {
- "latest": false,
- "stuck": false,
- "auto_devops": false,
- "yaml_errors": false,
- "retryable": false,
- "cancelable": false,
- "failure_reason": false
- },
- "details": {
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "duration": 53,
- "finished_at": "2018-10-31T16:35:31.299Z",
- "stages": [
- {
- "name": "prebuild",
- "title": "prebuild: passed",
- "groups": [
- {
- "name": "review-docs-deploy",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "manual play action",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 72469032,
- "name": "review-docs-deploy",
- "started": "2018-10-31T16:34:58.778Z",
- "archived": false,
- "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
- "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/retry",
- "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-06-05T11:31:30.495Z",
- "updated_at": "2018-10-31T16:35:31.251Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "manual play action",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild",
- "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=prebuild"
- },
- {
- "name": "test",
- "title": "test: passed",
- "groups": [
- {
- "name": "docs check links",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 72469033,
- "name": "docs check links",
- "started": "2018-06-05T11:31:33.240Z",
- "archived": false,
- "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
- "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-06-05T11:31:30.627Z",
- "updated_at": "2018-06-05T11:31:54.363Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#test",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253#test",
- "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=test"
- },
- {
- "name": "cleanup",
- "title": "cleanup: skipped",
- "groups": [
- {
- "name": "review-docs-cleanup",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual stop action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "stop",
- "title": "Stop",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "method": "post",
- "button_title": "Stop this environment"
- }
- },
- "jobs": [
- {
- "id": 72469034,
- "name": "review-docs-cleanup",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
- "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-06-05T11:31:30.760Z",
- "updated_at": "2018-06-05T11:31:56.037Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual stop action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "stop",
- "title": "Stop",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "method": "post",
- "button_title": "Stop this environment"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup",
- "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=cleanup"
- }
- ],
- "artifacts": [],
- "manual_actions": [
- {
- "name": "review-docs-cleanup",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "review-docs-deploy",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "playable": true,
- "scheduled": false
- }
- ],
- "scheduled_actions": []
- },
- "ref": {
- "name": "docs/add-development-guide-to-readme",
- "path": "/gitlab-org/gitlab-runner/commits/docs/add-development-guide-to-readme",
- "tag": false,
- "branch": true
- },
- "commit": {
- "id": "8083eb0a920572214d0dccedd7981f05d535ad46",
- "short_id": "8083eb0a",
- "title": "Add link to development guide in readme",
- "created_at": "2018-06-05T11:30:48.000Z",
- "parent_ids": ["1d7cf79b5a1a2121b9474ac20d61c1b8f621289d"],
- "message": "Add link to development guide in readme\n\nCloses https://gitlab.com/gitlab-org/gitlab-runner/issues/3122\n",
- "author_name": "Achilleas Pipinellis",
- "author_email": "axil@gitlab.com",
- "authored_date": "2018-06-05T11:30:48.000Z",
- "committer_name": "Achilleas Pipinellis",
- "committer_email": "axil@gitlab.com",
- "committed_date": "2018-06-05T11:30:48.000Z",
- "author": {
- "id": 3585,
- "name": "Achilleas Pipinellis",
- "username": "axil",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png",
- "web_url": "https://gitlab.com/axil",
- "status_tooltip_html": null,
- "path": "/axil"
- },
- "author_gravatar_url": "https://secure.gravatar.com/avatar/1d37af00eec153a8333a4ce18e9aea41?s=80\u0026d=identicon",
- "commit_url": "https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46",
- "commit_path": "/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46"
- },
- "triggered_by": {
- "id": 34993051,
- "user": {
- "id": 376774,
- "name": "Alessio Caiazza",
- "username": "nolith",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
- "web_url": "https://gitlab.com/nolith",
- "status_tooltip_html": null,
- "path": "/nolith"
- },
- "active": false,
- "coverage": null,
- "source": "pipeline",
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "details": {
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
- }
- },
- "project": {
- "id": 1794617,
- "name": "GitLab Docs",
- "full_path": "/gitlab-com/gitlab-docs",
- "full_name": "GitLab.com / GitLab Docs"
- }
- },
- "triggered": []
-}
diff --git a/spec/javascripts/pipelines/stores/pipeline_with_triggered_triggered_by.json b/spec/javascripts/pipelines/stores/pipeline_with_triggered_triggered_by.json
deleted file mode 100644
index 2402cbae6c8..00000000000
--- a/spec/javascripts/pipelines/stores/pipeline_with_triggered_triggered_by.json
+++ /dev/null
@@ -1,452 +0,0 @@
-{
- "id": 23211253,
- "user": {
- "id": 3585,
- "name": "Achilleas Pipinellis",
- "username": "axil",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png",
- "web_url": "https://gitlab.com/axil",
- "status_tooltip_html": "\u003cspan class=\"user-status-emoji has-tooltip\" title=\"\" data-html=\"true\" data-placement=\"top\"\u003e\u003cgl-emoji title=\"trumpet\" data-name=\"trumpet\" data-unicode-version=\"6.0\"\u003e🎺\u003c/gl-emoji\u003e\u003c/span\u003e",
- "path": "/axil"
- },
- "active": false,
- "coverage": null,
- "source": "push",
- "created_at": "2018-06-05T11:31:30.452Z",
- "updated_at": "2018-10-31T16:35:31.305Z",
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253",
- "flags": {
- "latest": false,
- "stuck": false,
- "auto_devops": false,
- "yaml_errors": false,
- "retryable": false,
- "cancelable": false,
- "failure_reason": false
- },
- "details": {
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "duration": 53,
- "finished_at": "2018-10-31T16:35:31.299Z",
- "stages": [
- {
- "name": "prebuild",
- "title": "prebuild: passed",
- "groups": [
- {
- "name": "review-docs-deploy",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "manual play action",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- },
- "jobs": [
- {
- "id": 72469032,
- "name": "review-docs-deploy",
- "started": "2018-10-31T16:34:58.778Z",
- "archived": false,
- "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
- "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/retry",
- "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-06-05T11:31:30.495Z",
- "updated_at": "2018-10-31T16:35:31.251Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "manual play action",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "play",
- "title": "Play",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "method": "post",
- "button_title": "Trigger this manual action"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild",
- "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=prebuild"
- },
- {
- "name": "test",
- "title": "test: passed",
- "groups": [
- {
- "name": "docs check links",
- "size": 1,
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- },
- "jobs": [
- {
- "id": 72469033,
- "name": "docs check links",
- "started": "2018-06-05T11:31:33.240Z",
- "archived": false,
- "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
- "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
- "playable": false,
- "scheduled": false,
- "created_at": "2018-06-05T11:31:30.627Z",
- "updated_at": "2018-06-05T11:31:54.363Z",
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
- "size": "svg-430",
- "title": "This job does not have a trace."
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
- "action": {
- "icon": "retry",
- "title": "Retry",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
- "method": "post",
- "button_title": "Retry this job"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_success",
- "text": "passed",
- "label": "passed",
- "group": "success",
- "tooltip": "passed",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#test",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
- },
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253#test",
- "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=test"
- },
- {
- "name": "cleanup",
- "title": "cleanup: skipped",
- "groups": [
- {
- "name": "review-docs-cleanup",
- "size": 1,
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual stop action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "stop",
- "title": "Stop",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "method": "post",
- "button_title": "Stop this environment"
- }
- },
- "jobs": [
- {
- "id": 72469034,
- "name": "review-docs-cleanup",
- "started": null,
- "archived": false,
- "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
- "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "playable": true,
- "scheduled": false,
- "created_at": "2018-06-05T11:31:30.760Z",
- "updated_at": "2018-06-05T11:31:56.037Z",
- "status": {
- "icon": "status_manual",
- "text": "manual",
- "label": "manual stop action",
- "group": "manual",
- "tooltip": "manual action",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
- "illustration": {
- "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
- "size": "svg-394",
- "title": "This job requires a manual action",
- "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
- },
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
- "action": {
- "icon": "stop",
- "title": "Stop",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "method": "post",
- "button_title": "Stop this environment"
- }
- }
- }
- ]
- }
- ],
- "status": {
- "icon": "status_skipped",
- "text": "skipped",
- "label": "skipped",
- "group": "skipped",
- "tooltip": "skipped",
- "has_details": true,
- "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
- },
- "path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup",
- "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=cleanup"
- }
- ],
- "artifacts": [],
- "manual_actions": [
- {
- "name": "review-docs-cleanup",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
- "playable": true,
- "scheduled": false
- },
- {
- "name": "review-docs-deploy",
- "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
- "playable": true,
- "scheduled": false
- }
- ],
- "scheduled_actions": []
- },
- "ref": {
- "name": "docs/add-development-guide-to-readme",
- "path": "/gitlab-org/gitlab-runner/commits/docs/add-development-guide-to-readme",
- "tag": false,
- "branch": true
- },
- "commit": {
- "id": "8083eb0a920572214d0dccedd7981f05d535ad46",
- "short_id": "8083eb0a",
- "title": "Add link to development guide in readme",
- "created_at": "2018-06-05T11:30:48.000Z",
- "parent_ids": ["1d7cf79b5a1a2121b9474ac20d61c1b8f621289d"],
- "message": "Add link to development guide in readme\n\nCloses https://gitlab.com/gitlab-org/gitlab-runner/issues/3122\n",
- "author_name": "Achilleas Pipinellis",
- "author_email": "axil@gitlab.com",
- "authored_date": "2018-06-05T11:30:48.000Z",
- "committer_name": "Achilleas Pipinellis",
- "committer_email": "axil@gitlab.com",
- "committed_date": "2018-06-05T11:30:48.000Z",
- "author": {
- "id": 3585,
- "name": "Achilleas Pipinellis",
- "username": "axil",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png",
- "web_url": "https://gitlab.com/axil",
- "status_tooltip_html": null,
- "path": "/axil"
- },
- "author_gravatar_url": "https://secure.gravatar.com/avatar/1d37af00eec153a8333a4ce18e9aea41?s=80\u0026d=identicon",
- "commit_url": "https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46",
- "commit_path": "/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46"
- },
- "triggered_by": {
- "id": 34993051,
- "user": {
- "id": 376774,
- "name": "Alessio Caiazza",
- "username": "nolith",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
- "web_url": "https://gitlab.com/nolith",
- "status_tooltip_html": null,
- "path": "/nolith"
- },
- "active": false,
- "coverage": null,
- "source": "pipeline",
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "details": {
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
- }
- },
- "project": {
- "id": 1794617,
- "name": "GitLab Docs",
- "full_path": "/gitlab-com/gitlab-docs",
- "full_name": "GitLab.com / GitLab Docs"
- }
- },
- "triggered": [
- {
- "id": 349233051,
- "user": {
- "id": 376774,
- "name": "Alessio Caiazza",
- "username": "nolith",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
- "web_url": "https://gitlab.com/nolith",
- "status_tooltip_html": null,
- "path": "/nolith"
- },
- "active": false,
- "coverage": null,
- "source": "pipeline",
- "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "details": {
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/349233051",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
- }
- },
- "project": {
- "id": 1794617,
- "name": "GitLab Docs",
- "full_path": "/gitlab-com/gitlab-docs",
- "full_name": "GitLab.com / GitLab Docs"
- }
- },
- {
- "id": 34993023,
- "user": {
- "id": 376774,
- "name": "Alessio Caiazza",
- "username": "nolith",
- "state": "active",
- "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
- "web_url": "https://gitlab.com/nolith",
- "status_tooltip_html": null,
- "path": "/nolith"
- },
- "active": false,
- "coverage": null,
- "source": "pipeline",
- "path": "/gitlab-com/gitlab-docs/pipelines/34993023",
- "details": {
- "status": {
- "icon": "status_failed",
- "text": "failed",
- "label": "failed",
- "group": "failed",
- "tooltip": "failed",
- "has_details": true,
- "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
- "illustration": null,
- "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
- }
- },
- "project": {
- "id": 1794617,
- "name": "GitLab Docs",
- "full_path": "/gitlab-com/gitlab-docs",
- "full_name": "GitLab.com / GitLab Docs"
- }
- }
- ]
-}
diff --git a/spec/javascripts/pipelines/time_ago_spec.js b/spec/javascripts/pipelines/time_ago_spec.js
deleted file mode 100644
index 42b34c82f89..00000000000
--- a/spec/javascripts/pipelines/time_ago_spec.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import Vue from 'vue';
-import timeAgo from '~/pipelines/components/time_ago.vue';
-
-describe('Timeago component', () => {
- let TimeAgo;
- beforeEach(() => {
- TimeAgo = Vue.extend(timeAgo);
- });
-
- describe('with duration', () => {
- it('should render duration and timer svg', () => {
- const component = new TimeAgo({
- propsData: {
- duration: 10,
- finishedTime: '',
- },
- }).$mount();
-
- expect(component.$el.querySelector('.duration')).toBeDefined();
- expect(component.$el.querySelector('.duration svg')).toBeDefined();
- });
- });
-
- describe('without duration', () => {
- it('should not render duration and timer svg', () => {
- const component = new TimeAgo({
- propsData: {
- duration: 0,
- finishedTime: '',
- },
- }).$mount();
-
- expect(component.$el.querySelector('.duration')).toBe(null);
- });
- });
-
- describe('with finishedTime', () => {
- it('should render time and calendar icon', () => {
- const component = new TimeAgo({
- propsData: {
- duration: 0,
- finishedTime: '2017-04-26T12:40:23.277Z',
- },
- }).$mount();
-
- expect(component.$el.querySelector('.finished-at')).toBeDefined();
- expect(component.$el.querySelector('.finished-at i.fa-calendar')).toBeDefined();
- expect(component.$el.querySelector('.finished-at time')).toBeDefined();
- });
- });
-
- describe('without finishedTime', () => {
- it('should not render time and calendar icon', () => {
- const component = new TimeAgo({
- propsData: {
- duration: 0,
- finishedTime: '',
- },
- }).$mount();
-
- expect(component.$el.querySelector('.finished-at')).toBe(null);
- });
- });
-});
diff --git a/spec/javascripts/prometheus_metrics/mock_data.js b/spec/javascripts/prometheus_metrics/mock_data.js
deleted file mode 100644
index 3af56df92e2..00000000000
--- a/spec/javascripts/prometheus_metrics/mock_data.js
+++ /dev/null
@@ -1,41 +0,0 @@
-export const metrics = [
- {
- group: 'Kubernetes',
- priority: 1,
- active_metrics: 4,
- metrics_missing_requirements: 0,
- },
- {
- group: 'HAProxy',
- priority: 2,
- active_metrics: 3,
- metrics_missing_requirements: 0,
- },
- {
- group: 'Apache',
- priority: 3,
- active_metrics: 5,
- metrics_missing_requirements: 0,
- },
-];
-
-export const missingVarMetrics = [
- {
- group: 'Kubernetes',
- priority: 1,
- active_metrics: 4,
- metrics_missing_requirements: 0,
- },
- {
- group: 'HAProxy',
- priority: 2,
- active_metrics: 3,
- metrics_missing_requirements: 1,
- },
- {
- group: 'Apache',
- priority: 3,
- active_metrics: 5,
- metrics_missing_requirements: 3,
- },
-];
diff --git a/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js b/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js
deleted file mode 100644
index dca3e1553b9..00000000000
--- a/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js
+++ /dev/null
@@ -1,178 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
-import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
-import PANEL_STATE from '~/prometheus_metrics/constants';
-import { metrics, missingVarMetrics } from './mock_data';
-
-describe('PrometheusMetrics', () => {
- const FIXTURE = 'services/prometheus/prometheus_service.html';
- preloadFixtures(FIXTURE);
-
- beforeEach(() => {
- loadFixtures(FIXTURE);
- });
-
- describe('constructor', () => {
- let prometheusMetrics;
-
- beforeEach(() => {
- prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
- });
-
- it('should initialize wrapper element refs on class object', () => {
- expect(prometheusMetrics.$wrapper).toBeDefined();
- expect(prometheusMetrics.$monitoredMetricsPanel).toBeDefined();
- expect(prometheusMetrics.$monitoredMetricsCount).toBeDefined();
- expect(prometheusMetrics.$monitoredMetricsLoading).toBeDefined();
- expect(prometheusMetrics.$monitoredMetricsEmpty).toBeDefined();
- expect(prometheusMetrics.$monitoredMetricsList).toBeDefined();
- expect(prometheusMetrics.$missingEnvVarPanel).toBeDefined();
- expect(prometheusMetrics.$panelToggle).toBeDefined();
- expect(prometheusMetrics.$missingEnvVarMetricCount).toBeDefined();
- expect(prometheusMetrics.$missingEnvVarMetricsList).toBeDefined();
- });
-
- it('should initialize metadata on class object', () => {
- expect(prometheusMetrics.backOffRequestCounter).toEqual(0);
- expect(prometheusMetrics.activeMetricsEndpoint).toContain('/test');
- });
- });
-
- describe('showMonitoringMetricsPanelState', () => {
- let prometheusMetrics;
-
- beforeEach(() => {
- prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
- });
-
- it('should show loading state when called with `loading`', () => {
- prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.LOADING);
-
- expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy();
- expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeTruthy();
- expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeTruthy();
- });
-
- it('should show metrics list when called with `list`', () => {
- prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.LIST);
-
- expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
- expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeTruthy();
- expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy();
- });
-
- it('should show empty state when called with `empty`', () => {
- prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
-
- expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
- expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeFalsy();
- expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeTruthy();
- });
- });
-
- describe('populateActiveMetrics', () => {
- let prometheusMetrics;
-
- beforeEach(() => {
- prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
- });
-
- it('should show monitored metrics list', () => {
- prometheusMetrics.populateActiveMetrics(metrics);
-
- const $metricsListLi = prometheusMetrics.$monitoredMetricsList.find('li');
-
- expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
- expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy();
-
- expect(prometheusMetrics.$monitoredMetricsCount.text()).toEqual(
- '3 exporters with 12 metrics were found',
- );
-
- expect($metricsListLi.length).toEqual(metrics.length);
- expect(
- $metricsListLi
- .first()
- .find('.badge')
- .text(),
- ).toEqual(`${metrics[0].active_metrics}`);
- });
-
- it('should show missing environment variables list', () => {
- prometheusMetrics.populateActiveMetrics(missingVarMetrics);
-
- expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
- expect(prometheusMetrics.$missingEnvVarPanel.hasClass('hidden')).toBeFalsy();
-
- expect(prometheusMetrics.$missingEnvVarMetricCount.text()).toEqual('2');
- expect(prometheusMetrics.$missingEnvVarPanel.find('li').length).toEqual(2);
- expect(prometheusMetrics.$missingEnvVarPanel.find('.flash-container')).toBeDefined();
- });
- });
-
- describe('loadActiveMetrics', () => {
- let prometheusMetrics;
- let mock;
-
- function mockSuccess() {
- mock.onGet(prometheusMetrics.activeMetricsEndpoint).reply(200, {
- data: metrics,
- success: true,
- });
- }
-
- function mockError() {
- mock.onGet(prometheusMetrics.activeMetricsEndpoint).networkError();
- }
-
- beforeEach(() => {
- spyOn(axios, 'get').and.callThrough();
-
- prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
-
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('should show loader animation while response is being loaded and hide it when request is complete', done => {
- mockSuccess();
-
- prometheusMetrics.loadActiveMetrics();
-
- expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy();
- expect(axios.get).toHaveBeenCalledWith(prometheusMetrics.activeMetricsEndpoint);
-
- setTimeout(() => {
- expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
- done();
- });
- });
-
- it('should show empty state if response failed to load', done => {
- mockError();
-
- prometheusMetrics.loadActiveMetrics();
-
- setTimeout(() => {
- expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
- expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeFalsy();
- done();
- });
- });
-
- it('should populate metrics list once response is loaded', done => {
- spyOn(prometheusMetrics, 'populateActiveMetrics');
- mockSuccess();
-
- prometheusMetrics.loadActiveMetrics();
-
- setTimeout(() => {
- expect(prometheusMetrics.populateActiveMetrics).toHaveBeenCalledWith(metrics);
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js b/spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js
deleted file mode 100644
index d8bdf69dfee..00000000000
--- a/spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js
+++ /dev/null
@@ -1,88 +0,0 @@
-import { mount, createLocalVue } from '@vue/test-utils';
-import MockAdapter from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
-import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
-import RelatedMergeRequests from '~/related_merge_requests/components/related_merge_requests.vue';
-import createStore from '~/related_merge_requests/store/index';
-
-const FIXTURE_PATH = 'issues/related_merge_requests.json';
-const API_ENDPOINT = '/api/v4/projects/2/issues/33/related_merge_requests';
-const localVue = createLocalVue();
-
-describe('RelatedMergeRequests', () => {
- let wrapper;
- let mock;
- let mockData;
-
- beforeEach(done => {
- loadFixtures(FIXTURE_PATH);
- mockData = getJSONFixture(FIXTURE_PATH);
- mock = new MockAdapter(axios);
- mock.onGet(`${API_ENDPOINT}?per_page=100`).reply(200, mockData, { 'x-total': 2 });
-
- wrapper = mount(localVue.extend(RelatedMergeRequests), {
- localVue,
- store: createStore(),
- propsData: {
- endpoint: API_ENDPOINT,
- projectNamespace: 'gitlab-org',
- projectPath: 'gitlab-ce',
- },
- });
-
- setTimeout(done);
- });
-
- afterEach(() => {
- wrapper.destroy();
- mock.restore();
- });
-
- describe('methods', () => {
- describe('getAssignees', () => {
- const assignees = [{ name: 'foo' }, { name: 'bar' }];
-
- describe('when there is assignees array', () => {
- it('should return assignees array', () => {
- const mr = { assignees };
-
- expect(wrapper.vm.getAssignees(mr)).toEqual(assignees);
- });
- });
-
- it('should return an array with single assingee', () => {
- const mr = { assignee: assignees[0] };
-
- expect(wrapper.vm.getAssignees(mr)).toEqual([assignees[0]]);
- });
-
- it('should return empty array when assignee is not set', () => {
- expect(wrapper.vm.getAssignees({})).toEqual([]);
- expect(wrapper.vm.getAssignees({ assignee: null })).toEqual([]);
- });
- });
- });
-
- describe('template', () => {
- it('should render related merge request items', () => {
- expect(wrapper.find('.js-items-count').text()).toEqual('2');
- expect(wrapper.findAll(RelatedIssuableItem).length).toEqual(2);
-
- const props = wrapper
- .findAll(RelatedIssuableItem)
- .at(1)
- .props();
- const data = mockData[1];
-
- expect(props.idKey).toEqual(data.id);
- expect(props.pathIdSeparator).toEqual('!');
- expect(props.pipelineStatus).toBe(data.head_pipeline.detailed_status);
- expect(props.assignees).toEqual([data.assignee]);
- expect(props.isMergeRequest).toBe(true);
- expect(props.confidential).toEqual(false);
- expect(props.title).toEqual(data.title);
- expect(props.state).toEqual(data.state);
- expect(props.createdAt).toEqual(data.created_at);
- });
- });
-});
diff --git a/spec/javascripts/related_merge_requests/store/actions_spec.js b/spec/javascripts/related_merge_requests/store/actions_spec.js
deleted file mode 100644
index c4cd9f5f803..00000000000
--- a/spec/javascripts/related_merge_requests/store/actions_spec.js
+++ /dev/null
@@ -1,110 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import testAction from 'spec/helpers/vuex_action_helper';
-import axios from '~/lib/utils/axios_utils';
-import * as types from '~/related_merge_requests/store/mutation_types';
-import actionsModule, * as actions from '~/related_merge_requests/store/actions';
-
-describe('RelatedMergeRequest store actions', () => {
- let state;
- let flashSpy;
- let mock;
-
- beforeEach(() => {
- state = {
- apiEndpoint: '/api/related_merge_requests',
- };
- flashSpy = spyOnDependency(actionsModule, 'createFlash');
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- describe('setInitialState', () => {
- it('commits types.SET_INITIAL_STATE with given props', done => {
- const props = { a: 1, b: 2 };
-
- testAction(
- actions.setInitialState,
- props,
- {},
- [{ type: types.SET_INITIAL_STATE, payload: props }],
- [],
- done,
- );
- });
- });
-
- describe('requestData', () => {
- it('commits types.REQUEST_DATA', done => {
- testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], [], done);
- });
- });
-
- describe('receiveDataSuccess', () => {
- it('commits types.RECEIVE_DATA_SUCCESS with data', done => {
- const data = { a: 1, b: 2 };
-
- testAction(
- actions.receiveDataSuccess,
- data,
- {},
- [{ type: types.RECEIVE_DATA_SUCCESS, payload: data }],
- [],
- done,
- );
- });
- });
-
- describe('receiveDataError', () => {
- it('commits types.RECEIVE_DATA_ERROR', done => {
- testAction(
- actions.receiveDataError,
- null,
- {},
- [{ type: types.RECEIVE_DATA_ERROR }],
- [],
- done,
- );
- });
- });
-
- describe('fetchMergeRequests', () => {
- describe('for a successful request', () => {
- it('should dispatch success action', done => {
- const data = { a: 1 };
- mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(200, data, { 'x-total': 2 });
-
- testAction(
- actions.fetchMergeRequests,
- null,
- state,
- [],
- [{ type: 'requestData' }, { type: 'receiveDataSuccess', payload: { data, total: 2 } }],
- done,
- );
- });
- });
-
- describe('for a failing request', () => {
- it('should dispatch error action', done => {
- mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(400);
-
- testAction(
- actions.fetchMergeRequests,
- null,
- state,
- [],
- [{ type: 'requestData' }, { type: 'receiveDataError' }],
- () => {
- expect(flashSpy).toHaveBeenCalledTimes(1);
- expect(flashSpy).toHaveBeenCalledWith(jasmine.stringMatching('Something went wrong'));
-
- done();
- },
- );
- });
- });
- });
-});
diff --git a/spec/javascripts/reports/components/grouped_test_reports_app_spec.js b/spec/javascripts/reports/components/grouped_test_reports_app_spec.js
deleted file mode 100644
index bafc47c952a..00000000000
--- a/spec/javascripts/reports/components/grouped_test_reports_app_spec.js
+++ /dev/null
@@ -1,239 +0,0 @@
-import Vue from 'vue';
-import MockAdapter from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
-import state from '~/reports/store/state';
-import component from '~/reports/components/grouped_test_reports_app.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
-import newFailedTestReports from '../mock_data/new_failures_report.json';
-import newErrorsTestReports from '../mock_data/new_errors_report.json';
-import successTestReports from '../mock_data/no_failures_report.json';
-import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json';
-import resolvedFailures from '../mock_data/resolved_failures.json';
-
-describe('Grouped Test Reports App', () => {
- let vm;
- let mock;
- const Component = Vue.extend(component);
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- vm.$store.replaceState(state());
- vm.$destroy();
- mock.restore();
- });
-
- describe('with success result', () => {
- beforeEach(() => {
- mock.onGet('test_results.json').reply(200, successTestReports, {});
- vm = mountComponent(Component, {
- endpoint: 'test_results.json',
- });
- });
-
- it('renders success summary text', done => {
- setTimeout(() => {
- expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
- expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
- 'Test summary contained no changed test results out of 11 total tests',
- );
-
- expect(vm.$el.textContent).toContain(
- 'rspec:pg found no changed test results out of 8 total tests',
- );
-
- expect(vm.$el.textContent).toContain(
- 'java ant found no changed test results out of 3 total tests',
- );
- done();
- }, 0);
- });
- });
-
- describe('with 204 result', () => {
- beforeEach(() => {
- mock.onGet('test_results.json').reply(204, {}, {});
- vm = mountComponent(Component, {
- endpoint: 'test_results.json',
- });
- });
-
- it('renders success summary text', done => {
- setTimeout(() => {
- expect(vm.$el.querySelector('.gl-spinner')).not.toBeNull();
- expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
- 'Test summary results are being parsed',
- );
-
- done();
- }, 0);
- });
- });
-
- describe('with new failed result', () => {
- beforeEach(() => {
- mock.onGet('test_results.json').reply(200, newFailedTestReports, {});
- vm = mountComponent(Component, {
- endpoint: 'test_results.json',
- });
- });
-
- it('renders failed summary text + new badge', done => {
- setTimeout(() => {
- expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
- expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
- 'Test summary contained 2 failed out of 11 total tests',
- );
-
- expect(vm.$el.textContent).toContain('rspec:pg found 2 failed out of 8 total tests');
-
- expect(vm.$el.textContent).toContain('New');
- expect(vm.$el.textContent).toContain(
- 'java ant found no changed test results out of 3 total tests',
- );
- done();
- }, 0);
- });
- });
-
- describe('with new error result', () => {
- beforeEach(() => {
- mock.onGet('test_results.json').reply(200, newErrorsTestReports, {});
- vm = mountComponent(Component, {
- endpoint: 'test_results.json',
- });
- });
-
- it('renders error summary text + new badge', done => {
- setTimeout(() => {
- expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
- expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
- 'Test summary contained 2 errors out of 11 total tests',
- );
-
- expect(vm.$el.textContent).toContain('karma found 2 errors out of 3 total tests');
-
- expect(vm.$el.textContent).toContain('New');
- expect(vm.$el.textContent).toContain(
- 'rspec:pg found no changed test results out of 8 total tests',
- );
- done();
- }, 0);
- });
- });
-
- describe('with mixed results', () => {
- beforeEach(() => {
- mock.onGet('test_results.json').reply(200, mixedResultsTestReports, {});
- vm = mountComponent(Component, {
- endpoint: 'test_results.json',
- });
- });
-
- it('renders summary text', done => {
- setTimeout(() => {
- expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
- expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
- 'Test summary contained 2 failed and 2 fixed test results out of 11 total tests',
- );
-
- expect(vm.$el.textContent).toContain(
- 'rspec:pg found 1 failed and 2 fixed test results out of 8 total tests',
- );
-
- expect(vm.$el.textContent).toContain('New');
- expect(vm.$el.textContent).toContain(' java ant found 1 failed out of 3 total tests');
- done();
- }, 0);
- });
- });
-
- describe('with resolved failures and resolved errors', () => {
- beforeEach(() => {
- mock.onGet('test_results.json').reply(200, resolvedFailures, {});
- vm = mountComponent(Component, {
- endpoint: 'test_results.json',
- });
- });
-
- it('renders summary text', done => {
- setTimeout(() => {
- expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
- expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
- 'Test summary contained 4 fixed test results out of 11 total tests',
- );
-
- expect(vm.$el.textContent).toContain(
- 'rspec:pg found 4 fixed test results out of 8 total tests',
- );
- done();
- }, 0);
- });
-
- it('renders resolved failures', done => {
- setTimeout(() => {
- expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
- resolvedFailures.suites[0].resolved_failures[0].name,
- );
-
- expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
- resolvedFailures.suites[0].resolved_failures[1].name,
- );
- done();
- }, 0);
- });
-
- it('renders resolved errors', done => {
- setTimeout(() => {
- expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
- resolvedFailures.suites[0].resolved_errors[0].name,
- );
-
- expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
- resolvedFailures.suites[0].resolved_errors[1].name,
- );
- done();
- }, 0);
- });
- });
-
- describe('with error', () => {
- beforeEach(() => {
- mock.onGet('test_results.json').reply(500, {}, {});
- vm = mountComponent(Component, {
- endpoint: 'test_results.json',
- });
- });
-
- it('renders loading summary text with loading icon', done => {
- setTimeout(() => {
- expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
- 'Test summary failed loading results',
- );
- done();
- }, 0);
- });
- });
-
- describe('while loading', () => {
- beforeEach(() => {
- mock.onGet('test_results.json').reply(200, {}, {});
- vm = mountComponent(Component, {
- endpoint: 'test_results.json',
- });
- });
-
- it('renders loading summary text with loading icon', done => {
- expect(vm.$el.querySelector('.gl-spinner')).not.toBeNull();
- expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
- 'Test summary results are being parsed',
- );
-
- setTimeout(() => {
- done();
- }, 0);
- });
- });
-});
diff --git a/spec/javascripts/reports/components/modal_open_name_spec.js b/spec/javascripts/reports/components/modal_open_name_spec.js
deleted file mode 100644
index ae1fb2bf187..00000000000
--- a/spec/javascripts/reports/components/modal_open_name_spec.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import component from '~/reports/components/modal_open_name.vue';
-
-Vue.use(Vuex);
-
-describe('Modal open name', () => {
- const Component = Vue.extend(component);
- let vm;
-
- const store = new Vuex.Store({
- actions: {
- openModal: () => {},
- },
- state: {},
- mutations: {},
- });
-
- beforeEach(() => {
- vm = mountComponentWithStore(Component, {
- store,
- props: {
- issue: {
- title: 'Issue',
- },
- status: 'failed',
- },
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders the issue name', () => {
- expect(vm.$el.textContent.trim()).toEqual('Issue');
- });
-
- it('calls openModal actions when button is clicked', () => {
- spyOn(vm, 'openModal');
-
- vm.$el.click();
-
- expect(vm.openModal).toHaveBeenCalled();
- });
-});
diff --git a/spec/javascripts/reports/components/summary_row_spec.js b/spec/javascripts/reports/components/summary_row_spec.js
deleted file mode 100644
index a19fbad403c..00000000000
--- a/spec/javascripts/reports/components/summary_row_spec.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import component from '~/reports/components/summary_row.vue';
-
-describe('Summary row', () => {
- const Component = Vue.extend(component);
- let vm;
-
- const props = {
- summary: 'SAST detected 1 new vulnerability and 1 fixed vulnerability',
- popoverOptions: {
- title: 'Static Application Security Testing (SAST)',
- content: '<a>Learn more about SAST</a>',
- },
- statusIcon: 'warning',
- };
-
- beforeEach(() => {
- vm = mountComponent(Component, props);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders provided summary', () => {
- expect(
- vm.$el.querySelector('.report-block-list-issue-description-text').textContent.trim(),
- ).toEqual(props.summary);
- });
-
- it('renders provided icon', () => {
- expect(vm.$el.querySelector('.report-block-list-icon span').classList).toContain(
- 'js-ci-status-icon-warning',
- );
- });
-});
diff --git a/spec/javascripts/reports/components/test_issue_body_spec.js b/spec/javascripts/reports/components/test_issue_body_spec.js
deleted file mode 100644
index 9c1cec4c9bc..00000000000
--- a/spec/javascripts/reports/components/test_issue_body_spec.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import Vue from 'vue';
-import component from '~/reports/components/test_issue_body.vue';
-import createStore from '~/reports/store';
-import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper';
-import { trimText } from '../../helpers/text_helper';
-import { issue } from '../mock_data/mock_data';
-
-describe('Test Issue body', () => {
- let vm;
- const Component = Vue.extend(component);
- const store = createStore();
-
- const commonProps = {
- issue,
- status: 'failed',
- };
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('on click', () => {
- it('calls openModal action', () => {
- vm = mountComponentWithStore(Component, {
- store,
- props: commonProps,
- });
-
- spyOn(vm, 'openModal');
-
- vm.$el.querySelector('button').click();
-
- expect(vm.openModal).toHaveBeenCalledWith({
- issue: commonProps.issue,
- });
- });
- });
-
- describe('is new', () => {
- beforeEach(() => {
- vm = mountComponentWithStore(Component, {
- store,
- props: Object.assign({}, commonProps, { isNew: true }),
- });
- });
-
- it('renders issue name', () => {
- expect(vm.$el.textContent).toContain(commonProps.issue.name);
- });
-
- it('renders new badge', () => {
- expect(trimText(vm.$el.querySelector('.badge').textContent)).toEqual('New');
- });
- });
-
- describe('not new', () => {
- beforeEach(() => {
- vm = mountComponentWithStore(Component, {
- store,
- props: commonProps,
- });
- });
-
- it('renders issue name', () => {
- expect(vm.$el.textContent).toContain(commonProps.issue.name);
- });
-
- it('does not renders new badge', () => {
- expect(vm.$el.querySelector('.badge')).toEqual(null);
- });
- });
-});
diff --git a/spec/javascripts/reports/mock_data/mock_data.js b/spec/javascripts/reports/mock_data/mock_data.js
deleted file mode 100644
index 0d90253bad2..00000000000
--- a/spec/javascripts/reports/mock_data/mock_data.js
+++ /dev/null
@@ -1,8 +0,0 @@
-// eslint-disable-next-line import/prefer-default-export
-export const issue = {
- result: 'failure',
- name: 'Test#sum when a is 1 and b is 2 returns summary',
- execution_time: 0.009411,
- system_output:
- "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in \u003ctop (required)\u003e'",
-};
diff --git a/spec/javascripts/reports/store/actions_spec.js b/spec/javascripts/reports/store/actions_spec.js
deleted file mode 100644
index 18fdb179597..00000000000
--- a/spec/javascripts/reports/store/actions_spec.js
+++ /dev/null
@@ -1,171 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import testAction from 'spec/helpers/vuex_action_helper';
-import { TEST_HOST } from 'spec/test_constants';
-import axios from '~/lib/utils/axios_utils';
-import {
- setEndpoint,
- requestReports,
- fetchReports,
- stopPolling,
- clearEtagPoll,
- receiveReportsSuccess,
- receiveReportsError,
- openModal,
- setModalData,
-} from '~/reports/store/actions';
-import state from '~/reports/store/state';
-import * as types from '~/reports/store/mutation_types';
-
-describe('Reports Store Actions', () => {
- let mockedState;
-
- beforeEach(() => {
- mockedState = state();
- });
-
- describe('setEndpoint', () => {
- it('should commit SET_ENDPOINT mutation', done => {
- testAction(
- setEndpoint,
- 'endpoint.json',
- mockedState,
- [{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }],
- [],
- done,
- );
- });
- });
-
- describe('requestReports', () => {
- it('should commit REQUEST_REPORTS mutation', done => {
- testAction(requestReports, null, mockedState, [{ type: types.REQUEST_REPORTS }], [], done);
- });
- });
-
- describe('fetchReports', () => {
- let mock;
-
- beforeEach(() => {
- mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- stopPolling();
- clearEtagPoll();
- });
-
- describe('success', () => {
- it('dispatches requestReports and receiveReportsSuccess ', done => {
- mock
- .onGet(`${TEST_HOST}/endpoint.json`)
- .replyOnce(200, { summary: {}, suites: [{ name: 'rspec' }] });
-
- testAction(
- fetchReports,
- null,
- mockedState,
- [],
- [
- {
- type: 'requestReports',
- },
- {
- payload: { data: { summary: {}, suites: [{ name: 'rspec' }] }, status: 200 },
- type: 'receiveReportsSuccess',
- },
- ],
- done,
- );
- });
- });
-
- describe('error', () => {
- beforeEach(() => {
- mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
- });
-
- it('dispatches requestReports and receiveReportsError ', done => {
- testAction(
- fetchReports,
- null,
- mockedState,
- [],
- [
- {
- type: 'requestReports',
- },
- {
- type: 'receiveReportsError',
- },
- ],
- done,
- );
- });
- });
- });
-
- describe('receiveReportsSuccess', () => {
- it('should commit RECEIVE_REPORTS_SUCCESS mutation with 200', done => {
- testAction(
- receiveReportsSuccess,
- { data: { summary: {} }, status: 200 },
- mockedState,
- [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: { summary: {} } }],
- [],
- done,
- );
- });
-
- it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', done => {
- testAction(
- receiveReportsSuccess,
- { data: { summary: {} }, status: 204 },
- mockedState,
- [],
- [],
- done,
- );
- });
- });
-
- describe('receiveReportsError', () => {
- it('should commit RECEIVE_REPORTS_ERROR mutation', done => {
- testAction(
- receiveReportsError,
- null,
- mockedState,
- [{ type: types.RECEIVE_REPORTS_ERROR }],
- [],
- done,
- );
- });
- });
-
- describe('openModal', () => {
- it('should dispatch setModalData', done => {
- testAction(
- openModal,
- { name: 'foo' },
- mockedState,
- [],
- [{ type: 'setModalData', payload: { name: 'foo' } }],
- done,
- );
- });
- });
-
- describe('setModalData', () => {
- it('should commit SET_ISSUE_MODAL_DATA', done => {
- testAction(
- setModalData,
- { name: 'foo' },
- mockedState,
- [{ type: types.SET_ISSUE_MODAL_DATA, payload: { name: 'foo' } }],
- [],
- done,
- );
- });
- });
-});
diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js
index e9bc1fc51e8..4f42d4880e8 100644
--- a/spec/javascripts/search_autocomplete_spec.js
+++ b/spec/javascripts/search_autocomplete_spec.js
@@ -188,4 +188,28 @@ describe('Search autocomplete dropdown', () => {
// example) on JavaScript-created keypresses.
expect(submitSpy).not.toHaveBeenTriggered();
});
+
+ describe('disableAutocomplete', function() {
+ beforeEach(function() {
+ widget.enableAutocomplete();
+ });
+
+ it('should close the Dropdown', function() {
+ const toggleSpy = spyOn(widget.dropdownToggle, 'dropdown');
+
+ widget.dropdown.addClass('show');
+ widget.disableAutocomplete();
+
+ expect(toggleSpy).toHaveBeenCalledWith('toggle');
+ });
+ });
+
+ describe('enableAutocomplete', function() {
+ it('should open the Dropdown', function() {
+ const toggleSpy = spyOn(widget.dropdownToggle, 'dropdown');
+ widget.enableAutocomplete();
+
+ expect(toggleSpy).toHaveBeenCalledWith('toggle');
+ });
+ });
});
diff --git a/spec/javascripts/sidebar/components/time_tracking/time_tracker_spec.js b/spec/javascripts/sidebar/components/time_tracking/time_tracker_spec.js
deleted file mode 100644
index 1580f32cfca..00000000000
--- a/spec/javascripts/sidebar/components/time_tracking/time_tracker_spec.js
+++ /dev/null
@@ -1,278 +0,0 @@
-import Vue from 'vue';
-
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
-
-describe('Issuable Time Tracker', () => {
- let initialData;
- let vm;
-
- const initTimeTrackingComponent = ({
- timeEstimate,
- timeSpent,
- timeEstimateHumanReadable,
- timeSpentHumanReadable,
- limitToHours,
- }) => {
- setFixtures(`
- <div>
- <div id="mock-container"></div>
- </div>
- `);
-
- initialData = {
- timeEstimate,
- timeSpent,
- humanTimeEstimate: timeEstimateHumanReadable,
- humanTimeSpent: timeSpentHumanReadable,
- limitToHours: Boolean(limitToHours),
- rootPath: '/',
- };
-
- const TimeTrackingComponent = Vue.extend({
- ...TimeTracker,
- components: {
- ...TimeTracker.components,
- transition: {
- // disable animations
- template: '<div><slot></slot></div>',
- },
- },
- });
- vm = mountComponent(TimeTrackingComponent, initialData, '#mock-container');
- };
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('Initialization', () => {
- beforeEach(() => {
- initTimeTrackingComponent({
- timeEstimate: 10000, // 2h 46m
- timeSpent: 5000, // 1h 23m
- timeEstimateHumanReadable: '2h 46m',
- timeSpentHumanReadable: '1h 23m',
- });
- });
-
- it('should return something defined', () => {
- expect(vm).toBeDefined();
- });
-
- it('should correctly set timeEstimate', done => {
- Vue.nextTick(() => {
- expect(vm.timeEstimate).toBe(initialData.timeEstimate);
- done();
- });
- });
-
- it('should correctly set time_spent', done => {
- Vue.nextTick(() => {
- expect(vm.timeSpent).toBe(initialData.timeSpent);
- done();
- });
- });
- });
-
- describe('Content Display', () => {
- describe('Panes', () => {
- describe('Comparison pane', () => {
- beforeEach(() => {
- initTimeTrackingComponent({
- timeEstimate: 100000, // 1d 3h
- timeSpent: 5000, // 1h 23m
- timeEstimateHumanReadable: '1d 3h',
- timeSpentHumanReadable: '1h 23m',
- });
- });
-
- it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', done => {
- Vue.nextTick(() => {
- expect(vm.showComparisonState).toBe(true);
- const $comparisonPane = vm.$el.querySelector('.time-tracking-comparison-pane');
-
- expect($comparisonPane).toBeVisible();
- done();
- });
- });
-
- it('should show full times when the sidebar is collapsed', done => {
- Vue.nextTick(() => {
- const timeTrackingText = vm.$el.querySelector('.time-tracking-collapsed-summary span')
- .innerText;
-
- expect(timeTrackingText).toBe('1h 23m / 1d 3h');
- done();
- });
- });
-
- describe('Remaining meter', () => {
- it('should display the remaining meter with the correct width', done => {
- Vue.nextTick(() => {
- expect(
- vm.$el.querySelector('.time-tracking-comparison-pane .progress[value="5"]'),
- ).not.toBeNull();
- done();
- });
- });
-
- it('should display the remaining meter with the correct background color when within estimate', done => {
- Vue.nextTick(() => {
- expect(
- vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="primary"]'),
- ).not.toBeNull();
- done();
- });
- });
-
- it('should display the remaining meter with the correct background color when over estimate', done => {
- vm.timeEstimate = 10000; // 2h 46m
- vm.timeSpent = 20000000; // 231 days
- Vue.nextTick(() => {
- expect(
- vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="danger"]'),
- ).not.toBeNull();
- done();
- });
- });
- });
- });
-
- describe('Comparison pane when limitToHours is true', () => {
- beforeEach(() => {
- initTimeTrackingComponent({
- timeEstimate: 100000, // 1d 3h
- timeSpent: 5000, // 1h 23m
- timeEstimateHumanReadable: '',
- timeSpentHumanReadable: '',
- limitToHours: true,
- });
- });
-
- it('should show the correct tooltip text', done => {
- Vue.nextTick(() => {
- expect(vm.showComparisonState).toBe(true);
- const $title = vm.$el.querySelector('.time-tracking-content .compare-meter').dataset
- .originalTitle;
-
- expect($title).toBe('Time remaining: 26h 23m');
- done();
- });
- });
- });
-
- describe('Estimate only pane', () => {
- beforeEach(() => {
- initTimeTrackingComponent({
- timeEstimate: 10000, // 2h 46m
- timeSpent: 0,
- timeEstimateHumanReadable: '2h 46m',
- timeSpentHumanReadable: '',
- });
- });
-
- it('should display the human readable version of time estimated', done => {
- Vue.nextTick(() => {
- const estimateText = vm.$el.querySelector('.time-tracking-estimate-only-pane')
- .innerText;
- const correctText = 'Estimated: 2h 46m';
-
- expect(estimateText).toBe(correctText);
- done();
- });
- });
- });
-
- describe('Spent only pane', () => {
- beforeEach(() => {
- initTimeTrackingComponent({
- timeEstimate: 0,
- timeSpent: 5000, // 1h 23m
- timeEstimateHumanReadable: '2h 46m',
- timeSpentHumanReadable: '1h 23m',
- });
- });
-
- it('should display the human readable version of time spent', done => {
- Vue.nextTick(() => {
- const spentText = vm.$el.querySelector('.time-tracking-spend-only-pane').innerText;
- const correctText = 'Spent: 1h 23m';
-
- expect(spentText).toBe(correctText);
- done();
- });
- });
- });
-
- describe('No time tracking pane', () => {
- beforeEach(() => {
- initTimeTrackingComponent({
- timeEstimate: 0,
- timeSpent: 0,
- timeEstimateHumanReadable: '',
- timeSpentHumanReadable: '',
- });
- });
-
- it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', done => {
- Vue.nextTick(() => {
- const $noTrackingPane = vm.$el.querySelector('.time-tracking-no-tracking-pane');
- const noTrackingText = $noTrackingPane.innerText;
- const correctText = 'No estimate or time spent';
-
- expect(vm.showNoTimeTrackingState).toBe(true);
- expect($noTrackingPane).toBeVisible();
- expect(noTrackingText).toBe(correctText);
- done();
- });
- });
- });
-
- describe('Help pane', () => {
- const helpButton = () => vm.$el.querySelector('.help-button');
- const closeHelpButton = () => vm.$el.querySelector('.close-help-button');
- const helpPane = () => vm.$el.querySelector('.time-tracking-help-state');
-
- beforeEach(done => {
- initTimeTrackingComponent({ timeEstimate: 0, timeSpent: 0 });
-
- Vue.nextTick()
- .then(done)
- .catch(done.fail);
- });
-
- it('should not show the "Help" pane by default', () => {
- expect(vm.showHelpState).toBe(false);
- expect(helpPane()).toBeNull();
- });
-
- it('should show the "Help" pane when help button is clicked', done => {
- helpButton().click();
-
- Vue.nextTick()
- .then(() => {
- expect(vm.showHelpState).toBe(true);
- expect(helpPane()).toBeVisible();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('should not show the "Help" pane when help button is clicked and then closed', done => {
- helpButton().click();
-
- Vue.nextTick()
- .then(() => closeHelpButton().click())
- .then(() => Vue.nextTick())
- .then(() => {
- expect(vm.showHelpState).toBe(false);
- expect(helpPane()).toBeNull();
- })
- .then(done)
- .catch(done.fail);
- });
- });
- });
- });
-});
diff --git a/spec/javascripts/sidebar/lock/edit_form_buttons_spec.js b/spec/javascripts/sidebar/lock/edit_form_buttons_spec.js
deleted file mode 100644
index c532554efb4..00000000000
--- a/spec/javascripts/sidebar/lock/edit_form_buttons_spec.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import editFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue';
-
-describe('EditFormButtons', () => {
- let vm1;
- let vm2;
-
- beforeEach(() => {
- const Component = Vue.extend(editFormButtons);
- const toggleForm = () => {};
- const updateLockedAttribute = () => {};
-
- vm1 = mountComponent(Component, {
- isLocked: true,
- toggleForm,
- updateLockedAttribute,
- });
-
- vm2 = mountComponent(Component, {
- isLocked: false,
- toggleForm,
- updateLockedAttribute,
- });
- });
-
- it('renders unlock or lock text based on locked state', () => {
- expect(vm1.$el.innerHTML.includes('Unlock')).toBe(true);
-
- expect(vm2.$el.innerHTML.includes('Lock')).toBe(true);
- });
-});
diff --git a/spec/javascripts/sidebar/lock/lock_issue_sidebar_spec.js b/spec/javascripts/sidebar/lock/lock_issue_sidebar_spec.js
deleted file mode 100644
index 5296908afe2..00000000000
--- a/spec/javascripts/sidebar/lock/lock_issue_sidebar_spec.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import Vue from 'vue';
-import { mockTracking, triggerEvent } from 'spec/helpers/tracking_helper';
-import lockIssueSidebar from '~/sidebar/components/lock/lock_issue_sidebar.vue';
-
-describe('LockIssueSidebar', () => {
- let vm1;
- let vm2;
-
- beforeEach(() => {
- const Component = Vue.extend(lockIssueSidebar);
-
- const mediator = {
- service: {
- update: Promise.resolve(true),
- },
-
- store: {
- isLockDialogOpen: false,
- },
- };
-
- vm1 = new Component({
- propsData: {
- isLocked: true,
- isEditable: true,
- mediator,
- issuableType: 'issue',
- },
- }).$mount();
-
- vm2 = new Component({
- propsData: {
- isLocked: false,
- isEditable: false,
- mediator,
- issuableType: 'merge_request',
- },
- }).$mount();
- });
-
- it('shows if locked and/or editable', () => {
- expect(vm1.$el.innerHTML.includes('Edit')).toBe(true);
-
- expect(vm1.$el.innerHTML.includes('Locked')).toBe(true);
-
- expect(vm2.$el.innerHTML.includes('Unlocked')).toBe(true);
- });
-
- it('displays the edit form when editable', done => {
- expect(vm1.isLockDialogOpen).toBe(false);
-
- vm1.$el.querySelector('.lock-edit').click();
-
- expect(vm1.isLockDialogOpen).toBe(true);
-
- vm1.$nextTick(() => {
- expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true);
-
- done();
- });
- });
-
- it('tracks an event when "Edit" is clicked', () => {
- const spy = mockTracking('_category_', vm1.$el, spyOn);
- triggerEvent('.lock-edit');
-
- expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', {
- label: 'right_sidebar',
- property: 'lock_issue',
- });
- });
-
- it('displays the edit form when opened from collapsed state', done => {
- expect(vm1.isLockDialogOpen).toBe(false);
-
- vm1.$el.querySelector('.sidebar-collapsed-icon').click();
-
- expect(vm1.isLockDialogOpen).toBe(true);
-
- setTimeout(() => {
- expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true);
-
- done();
- });
- });
-
- it('does not display the edit form when opened from collapsed state if not editable', done => {
- expect(vm2.isLockDialogOpen).toBe(false);
-
- vm2.$el.querySelector('.sidebar-collapsed-icon').click();
-
- Vue.nextTick()
- .then(() => {
- expect(vm2.isLockDialogOpen).toBe(false);
- })
- .then(done)
- .catch(done.fail);
- });
-});
diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js
deleted file mode 100644
index c869ff96933..00000000000
--- a/spec/javascripts/sidebar/mock_data.js
+++ /dev/null
@@ -1,7 +0,0 @@
-// No new code should be added to this file. Instead, modify the
-// file this one re-exports from. For more detail about why, see:
-// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/31349
-
-import mockData from '../../../spec/frontend/sidebar/mock_data';
-
-export default mockData;
diff --git a/spec/javascripts/sidebar/participants_spec.js b/spec/javascripts/sidebar/participants_spec.js
deleted file mode 100644
index 7e80e86f8ca..00000000000
--- a/spec/javascripts/sidebar/participants_spec.js
+++ /dev/null
@@ -1,202 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import participants from '~/sidebar/components/participants/participants.vue';
-
-const PARTICIPANT = {
- id: 1,
- state: 'active',
- username: 'marcene',
- name: 'Allie Will',
- web_url: 'foo.com',
- avatar_url: 'gravatar.com/avatar/xxx',
-};
-
-const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPANT, id: 3 }];
-
-describe('Participants', function() {
- let vm;
- let Participants;
-
- beforeEach(() => {
- Participants = Vue.extend(participants);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('collapsed sidebar state', () => {
- it('shows loading spinner when loading', () => {
- vm = mountComponent(Participants, {
- loading: true,
- });
-
- expect(vm.$el.querySelector('.js-participants-collapsed-loading-icon')).toBeDefined();
- });
-
- it('shows participant count when given', () => {
- vm = mountComponent(Participants, {
- loading: false,
- participants: PARTICIPANT_LIST,
- });
- const countEl = vm.$el.querySelector('.js-participants-collapsed-count');
-
- expect(countEl.textContent.trim()).toBe(`${PARTICIPANT_LIST.length}`);
- });
-
- it('shows full participant count when there are hidden participants', () => {
- vm = mountComponent(Participants, {
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants: 1,
- });
- const countEl = vm.$el.querySelector('.js-participants-collapsed-count');
-
- expect(countEl.textContent.trim()).toBe(`${PARTICIPANT_LIST.length}`);
- });
- });
-
- describe('expanded sidebar state', () => {
- it('shows loading spinner when loading', () => {
- vm = mountComponent(Participants, {
- loading: true,
- });
-
- expect(vm.$el.querySelector('.js-participants-expanded-loading-icon')).toBeDefined();
- });
-
- it('when only showing visible participants, shows an avatar only for each participant under the limit', done => {
- const numberOfLessParticipants = 2;
- vm = mountComponent(Participants, {
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants,
- });
- vm.isShowingMoreParticipants = false;
-
- Vue.nextTick()
- .then(() => {
- const participantEls = vm.$el.querySelectorAll('.js-participants-author');
-
- expect(participantEls.length).toBe(numberOfLessParticipants);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('when only showing all participants, each has an avatar', done => {
- const numberOfLessParticipants = 2;
- vm = mountComponent(Participants, {
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants,
- });
- vm.isShowingMoreParticipants = true;
-
- Vue.nextTick()
- .then(() => {
- const participantEls = vm.$el.querySelectorAll('.js-participants-author');
-
- expect(participantEls.length).toBe(PARTICIPANT_LIST.length);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('does not have more participants link when they can all be shown', () => {
- const numberOfLessParticipants = 100;
- vm = mountComponent(Participants, {
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants,
- });
- const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button');
-
- expect(PARTICIPANT_LIST.length).toBeLessThan(numberOfLessParticipants);
- expect(moreParticipantLink).toBeNull();
- });
-
- it('when too many participants, has more participants link to show more', done => {
- vm = mountComponent(Participants, {
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants: 2,
- });
- vm.isShowingMoreParticipants = false;
-
- Vue.nextTick()
- .then(() => {
- const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button');
-
- expect(moreParticipantLink.textContent.trim()).toBe('+ 1 more');
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('when too many participants and already showing them, has more participants link to show less', done => {
- vm = mountComponent(Participants, {
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants: 2,
- });
- vm.isShowingMoreParticipants = true;
-
- Vue.nextTick()
- .then(() => {
- const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button');
-
- expect(moreParticipantLink.textContent.trim()).toBe('- show less');
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('clicking more participants link emits event', () => {
- vm = mountComponent(Participants, {
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants: 2,
- });
- const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button');
-
- expect(vm.isShowingMoreParticipants).toBe(false);
-
- moreParticipantLink.click();
-
- expect(vm.isShowingMoreParticipants).toBe(true);
- });
-
- it('clicking on participants icon emits `toggleSidebar` event', () => {
- vm = mountComponent(Participants, {
- loading: false,
- participants: PARTICIPANT_LIST,
- numberOfLessParticipants: 2,
- });
- spyOn(vm, '$emit');
-
- const participantsIconEl = vm.$el.querySelector('.sidebar-collapsed-icon');
-
- participantsIconEl.click();
-
- expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar');
- });
- });
-
- describe('when not showing participants label', () => {
- beforeEach(() => {
- vm = mountComponent(Participants, {
- participants: PARTICIPANT_LIST,
- showParticipantLabel: false,
- });
- });
-
- it('does not show sidebar collapsed icon', () => {
- expect(vm.$el.querySelector('.sidebar-collapsed-icon')).not.toBeTruthy();
- });
-
- it('does not show participants label title', () => {
- expect(vm.$el.querySelector('.title')).not.toBeTruthy();
- });
- });
-});
diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js
deleted file mode 100644
index 2aa30fd1cc6..00000000000
--- a/spec/javascripts/sidebar/sidebar_mediator_spec.js
+++ /dev/null
@@ -1,134 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
-import SidebarMediator from '~/sidebar/sidebar_mediator';
-import SidebarStore from '~/sidebar/stores/sidebar_store';
-import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service';
-import Mock from './mock_data';
-
-const { mediator: mediatorMockData } = Mock;
-
-describe('Sidebar mediator', function() {
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
-
- this.mediator = new SidebarMediator(mediatorMockData);
- });
-
- afterEach(() => {
- SidebarService.singleton = null;
- SidebarStore.singleton = null;
- SidebarMediator.singleton = null;
- mock.restore();
- });
-
- it('assigns yourself ', () => {
- this.mediator.assignYourself();
-
- expect(this.mediator.store.currentUser).toEqual(mediatorMockData.currentUser);
- expect(this.mediator.store.assignees[0]).toEqual(mediatorMockData.currentUser);
- });
-
- it('saves assignees', done => {
- mock.onPut(mediatorMockData.endpoint).reply(200, {});
- this.mediator
- .saveAssignees('issue[assignee_ids]')
- .then(resp => {
- expect(resp.status).toEqual(200);
- done();
- })
- .catch(done.fail);
- });
-
- it('fetches the data', done => {
- const mockData = Mock.responseMap.GET[mediatorMockData.endpoint];
- mock.onGet(mediatorMockData.endpoint).reply(200, mockData);
-
- const mockGraphQlData = Mock.graphQlResponseData;
- spyOn(gqClient, 'query').and.returnValue({
- data: mockGraphQlData,
- });
-
- spyOn(this.mediator, 'processFetchedData').and.callThrough();
-
- this.mediator
- .fetch()
- .then(() => {
- expect(this.mediator.processFetchedData).toHaveBeenCalledWith(mockData, mockGraphQlData);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('processes fetched data', () => {
- const mockData = Mock.responseMap.GET[mediatorMockData.endpoint];
- this.mediator.processFetchedData(mockData);
-
- expect(this.mediator.store.assignees).toEqual(mockData.assignees);
- expect(this.mediator.store.humanTimeEstimate).toEqual(mockData.human_time_estimate);
- expect(this.mediator.store.humanTotalTimeSpent).toEqual(mockData.human_total_time_spent);
- expect(this.mediator.store.participants).toEqual(mockData.participants);
- expect(this.mediator.store.subscribed).toEqual(mockData.subscribed);
- expect(this.mediator.store.timeEstimate).toEqual(mockData.time_estimate);
- expect(this.mediator.store.totalTimeSpent).toEqual(mockData.total_time_spent);
- });
-
- it('sets moveToProjectId', () => {
- const projectId = 7;
- spyOn(this.mediator.store, 'setMoveToProjectId').and.callThrough();
-
- this.mediator.setMoveToProjectId(projectId);
-
- expect(this.mediator.store.setMoveToProjectId).toHaveBeenCalledWith(projectId);
- });
-
- it('fetches autocomplete projects', done => {
- const searchTerm = 'foo';
- mock.onGet(mediatorMockData.projectsAutocompleteEndpoint).reply(200, {});
- spyOn(this.mediator.service, 'getProjectsAutocomplete').and.callThrough();
- spyOn(this.mediator.store, 'setAutocompleteProjects').and.callThrough();
-
- this.mediator
- .fetchAutocompleteProjects(searchTerm)
- .then(() => {
- expect(this.mediator.service.getProjectsAutocomplete).toHaveBeenCalledWith(searchTerm);
- expect(this.mediator.store.setAutocompleteProjects).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('moves issue', done => {
- const mockData = Mock.responseMap.POST[mediatorMockData.moveIssueEndpoint];
- const moveToProjectId = 7;
- mock.onPost(mediatorMockData.moveIssueEndpoint).reply(200, mockData);
- this.mediator.store.setMoveToProjectId(moveToProjectId);
- spyOn(this.mediator.service, 'moveIssue').and.callThrough();
- const visitUrl = spyOnDependency(SidebarMediator, 'visitUrl');
-
- this.mediator
- .moveIssue()
- .then(() => {
- expect(this.mediator.service.moveIssue).toHaveBeenCalledWith(moveToProjectId);
- expect(visitUrl).toHaveBeenCalledWith(mockData.web_url);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('toggle subscription', done => {
- this.mediator.store.setSubscribedState(false);
- mock.onPost(mediatorMockData.toggleSubscriptionEndpoint).reply(200, {});
- spyOn(this.mediator.service, 'toggleSubscription').and.callThrough();
-
- this.mediator
- .toggleSubscription()
- .then(() => {
- expect(this.mediator.service.toggleSubscription).toHaveBeenCalled();
- expect(this.mediator.store.subscribed).toEqual(true);
- })
- .then(done)
- .catch(done.fail);
- });
-});
diff --git a/spec/javascripts/sidebar/sidebar_move_issue_spec.js b/spec/javascripts/sidebar/sidebar_move_issue_spec.js
deleted file mode 100644
index ec712450f2e..00000000000
--- a/spec/javascripts/sidebar/sidebar_move_issue_spec.js
+++ /dev/null
@@ -1,166 +0,0 @@
-import $ from 'jquery';
-import MockAdapter from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
-import SidebarMediator from '~/sidebar/sidebar_mediator';
-import SidebarStore from '~/sidebar/stores/sidebar_store';
-import SidebarService from '~/sidebar/services/sidebar_service';
-import SidebarMoveIssue from '~/sidebar/lib/sidebar_move_issue';
-import Mock from './mock_data';
-
-describe('SidebarMoveIssue', function() {
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- const mockData = Mock.responseMap.GET['/autocomplete/projects?project_id=15'];
- mock.onGet('/autocomplete/projects?project_id=15').reply(200, mockData);
- this.mediator = new SidebarMediator(Mock.mediator);
- this.$content = $(`
- <div class="dropdown">
- <div class="js-toggle"></div>
- <div class="dropdown-menu">
- <div class="dropdown-content"></div>
- </div>
- <div class="js-confirm-button"></div>
- </div>
- `);
- this.$toggleButton = this.$content.find('.js-toggle');
- this.$confirmButton = this.$content.find('.js-confirm-button');
-
- this.sidebarMoveIssue = new SidebarMoveIssue(
- this.mediator,
- this.$toggleButton,
- this.$confirmButton,
- );
- this.sidebarMoveIssue.init();
- });
-
- afterEach(() => {
- SidebarService.singleton = null;
- SidebarStore.singleton = null;
- SidebarMediator.singleton = null;
-
- this.sidebarMoveIssue.destroy();
- mock.restore();
- });
-
- describe('init', () => {
- it('should initialize the dropdown and listeners', () => {
- spyOn(this.sidebarMoveIssue, 'initDropdown');
- spyOn(this.sidebarMoveIssue, 'addEventListeners');
-
- this.sidebarMoveIssue.init();
-
- expect(this.sidebarMoveIssue.initDropdown).toHaveBeenCalled();
- expect(this.sidebarMoveIssue.addEventListeners).toHaveBeenCalled();
- });
- });
-
- describe('destroy', () => {
- it('should remove the listeners', () => {
- spyOn(this.sidebarMoveIssue, 'removeEventListeners');
-
- this.sidebarMoveIssue.destroy();
-
- expect(this.sidebarMoveIssue.removeEventListeners).toHaveBeenCalled();
- });
- });
-
- describe('initDropdown', () => {
- it('should initialize the gl_dropdown', () => {
- spyOn($.fn, 'glDropdown');
-
- this.sidebarMoveIssue.initDropdown();
-
- expect($.fn.glDropdown).toHaveBeenCalled();
- });
-
- it('escapes html from project name', done => {
- this.$toggleButton.dropdown('toggle');
-
- setTimeout(() => {
- expect(this.$content.find('.js-move-issue-dropdown-item')[1].innerHTML.trim()).toEqual(
- '&lt;img src=x onerror=alert(document.domain)&gt; foo / bar',
- );
- done();
- });
- });
- });
-
- describe('onConfirmClicked', () => {
- it('should move the issue with valid project ID', () => {
- spyOn(this.mediator, 'moveIssue').and.returnValue(Promise.resolve());
- this.mediator.setMoveToProjectId(7);
-
- this.sidebarMoveIssue.onConfirmClicked();
-
- expect(this.mediator.moveIssue).toHaveBeenCalled();
- expect(this.$confirmButton.prop('disabled')).toBeTruthy();
- expect(this.$confirmButton.hasClass('is-loading')).toBe(true);
- });
-
- it('should remove loading state from confirm button on failure', done => {
- spyOn(window, 'Flash');
- spyOn(this.mediator, 'moveIssue').and.returnValue(Promise.reject());
- this.mediator.setMoveToProjectId(7);
-
- this.sidebarMoveIssue.onConfirmClicked();
-
- expect(this.mediator.moveIssue).toHaveBeenCalled();
- // Wait for the move issue request to fail
- setTimeout(() => {
- expect(window.Flash).toHaveBeenCalled();
- expect(this.$confirmButton.prop('disabled')).toBeFalsy();
- expect(this.$confirmButton.hasClass('is-loading')).toBe(false);
- done();
- });
- });
-
- it('should not move the issue with id=0', () => {
- spyOn(this.mediator, 'moveIssue');
- this.mediator.setMoveToProjectId(0);
-
- this.sidebarMoveIssue.onConfirmClicked();
-
- expect(this.mediator.moveIssue).not.toHaveBeenCalled();
- });
- });
-
- it('should set moveToProjectId on dropdown item "No project" click', done => {
- spyOn(this.mediator, 'setMoveToProjectId');
-
- // Open the dropdown
- this.$toggleButton.dropdown('toggle');
-
- // Wait for the autocomplete request to finish
- setTimeout(() => {
- this.$content
- .find('.js-move-issue-dropdown-item')
- .eq(0)
- .trigger('click');
-
- expect(this.mediator.setMoveToProjectId).toHaveBeenCalledWith(0);
- expect(this.$confirmButton.prop('disabled')).toBeTruthy();
- done();
- }, 0);
- });
-
- it('should set moveToProjectId on dropdown item click', done => {
- spyOn(this.mediator, 'setMoveToProjectId');
-
- // Open the dropdown
- this.$toggleButton.dropdown('toggle');
-
- // Wait for the autocomplete request to finish
- setTimeout(() => {
- this.$content
- .find('.js-move-issue-dropdown-item')
- .eq(1)
- .trigger('click');
-
- expect(this.mediator.setMoveToProjectId).toHaveBeenCalledWith(20);
- expect(this.$confirmButton.attr('disabled')).toBe(undefined);
- done();
- }, 0);
- });
-});
diff --git a/spec/javascripts/sidebar/sidebar_subscriptions_spec.js b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js
deleted file mode 100644
index ee4516f3bcd..00000000000
--- a/spec/javascripts/sidebar/sidebar_subscriptions_spec.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import sidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_subscriptions.vue';
-import SidebarMediator from '~/sidebar/sidebar_mediator';
-import SidebarService from '~/sidebar/services/sidebar_service';
-import SidebarStore from '~/sidebar/stores/sidebar_store';
-import Mock from './mock_data';
-
-describe('Sidebar Subscriptions', function() {
- let vm;
- let SidebarSubscriptions;
-
- beforeEach(() => {
- SidebarSubscriptions = Vue.extend(sidebarSubscriptions);
- // Set up the stores, services, etc
- // eslint-disable-next-line no-new
- new SidebarMediator(Mock.mediator);
- });
-
- afterEach(() => {
- vm.$destroy();
- SidebarService.singleton = null;
- SidebarStore.singleton = null;
- SidebarMediator.singleton = null;
- });
-
- it('calls the mediator toggleSubscription on event', () => {
- const mediator = new SidebarMediator();
- spyOn(mediator, 'toggleSubscription').and.returnValue(Promise.resolve());
- vm = mountComponent(SidebarSubscriptions, {
- mediator,
- });
-
- vm.onToggleSubscription();
-
- expect(mediator.toggleSubscription).toHaveBeenCalled();
- });
-});
diff --git a/spec/javascripts/sidebar/subscriptions_spec.js b/spec/javascripts/sidebar/subscriptions_spec.js
deleted file mode 100644
index cdb39efbef8..00000000000
--- a/spec/javascripts/sidebar/subscriptions_spec.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockTracking } from 'spec/helpers/tracking_helper';
-import subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
-import eventHub from '~/sidebar/event_hub';
-
-describe('Subscriptions', function() {
- let vm;
- let Subscriptions;
-
- beforeEach(() => {
- Subscriptions = Vue.extend(subscriptions);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('shows loading spinner when loading', () => {
- vm = mountComponent(Subscriptions, {
- loading: true,
- subscribed: undefined,
- });
-
- expect(vm.$refs.toggleButton.isLoading).toBe(true);
- expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).toHaveClass(
- 'is-loading',
- );
- });
-
- it('is toggled "off" when currently not subscribed', () => {
- vm = mountComponent(Subscriptions, {
- subscribed: false,
- });
-
- expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).not.toHaveClass(
- 'is-checked',
- );
- });
-
- it('is toggled "on" when currently subscribed', () => {
- vm = mountComponent(Subscriptions, {
- subscribed: true,
- });
-
- expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).toHaveClass(
- 'is-checked',
- );
- });
-
- it('toggleSubscription method emits `toggleSubscription` event on eventHub and Component', () => {
- vm = mountComponent(Subscriptions, { subscribed: true });
- spyOn(eventHub, '$emit');
- spyOn(vm, '$emit');
- spyOn(vm, 'track');
-
- vm.toggleSubscription();
-
- expect(eventHub.$emit).toHaveBeenCalledWith('toggleSubscription', jasmine.any(Object));
- expect(vm.$emit).toHaveBeenCalledWith('toggleSubscription', jasmine.any(Object));
- });
-
- it('tracks the event when toggled', () => {
- vm = mountComponent(Subscriptions, { subscribed: true });
- const spy = mockTracking('_category_', vm.$el, spyOn);
- vm.toggleSubscription();
-
- expect(spy).toHaveBeenCalled();
- });
-
- it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => {
- vm = mountComponent(Subscriptions, { subscribed: true });
- spyOn(vm, '$emit');
-
- vm.onClickCollapsedIcon();
-
- expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar');
- });
-
- describe('given project emails are disabled', () => {
- const subscribeDisabledDescription = 'Notifications have been disabled';
-
- beforeEach(() => {
- vm = mountComponent(Subscriptions, {
- subscribed: false,
- projectEmailsDisabled: true,
- subscribeDisabledDescription,
- });
- });
-
- it('sets the correct display text', () => {
- expect(vm.$el.textContent).toContain(subscribeDisabledDescription);
- expect(vm.$refs.tooltip.dataset.originalTitle).toBe(subscribeDisabledDescription);
- });
-
- it('does not render the toggle button', () => {
- expect(vm.$refs.toggleButton).toBeUndefined();
- });
- });
-});
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js
index de1d351677c..3cbaa47c832 100644
--- a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js
@@ -153,7 +153,7 @@ describe('MRWidgetHeader', () => {
beforeEach(() => {
vm = mountComponent(Component, {
- mr: Object.assign({}, mrDefaultOptions),
+ mr: { ...mrDefaultOptions },
});
});
@@ -176,7 +176,7 @@ describe('MRWidgetHeader', () => {
});
it('renders web ide button in disabled state with no href', () => {
- const mr = Object.assign({}, mrDefaultOptions, { canPushToSourceBranch: false });
+ const mr = { ...mrDefaultOptions, canPushToSourceBranch: false };
vm = mountComponent(Component, { mr });
const link = vm.$el.querySelector('.js-web-ide');
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js
deleted file mode 100644
index 76827cde093..00000000000
--- a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import { mount, createLocalVue } from '@vue/test-utils';
-import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue';
-import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue';
-import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue';
-import { mockStore } from '../mock_data';
-
-const localVue = createLocalVue();
-
-describe('MrWidgetPipelineContainer', () => {
- let wrapper;
-
- const factory = (props = {}) => {
- wrapper = mount(localVue.extend(MrWidgetPipelineContainer), {
- propsData: {
- mr: Object.assign({}, mockStore),
- ...props,
- },
- localVue,
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('when pre merge', () => {
- beforeEach(() => {
- factory();
- });
-
- it('renders pipeline', () => {
- expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true);
- expect(wrapper.find(MrWidgetPipeline).props()).toEqual(
- jasmine.objectContaining({
- pipeline: mockStore.pipeline,
- pipelineCoverageDelta: mockStore.pipelineCoverageDelta,
- ciStatus: mockStore.ciStatus,
- hasCi: mockStore.hasCI,
- sourceBranch: mockStore.sourceBranch,
- sourceBranchLink: mockStore.sourceBranchLink,
- }),
- );
- });
-
- it('renders deployments', () => {
- const expectedProps = mockStore.deployments.map(dep =>
- jasmine.objectContaining({
- deployment: dep,
- showMetrics: false,
- }),
- );
-
- const deployments = wrapper.findAll('.mr-widget-extension .js-pre-deployment');
-
- expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps);
- });
- });
-
- describe('when post merge', () => {
- beforeEach(() => {
- factory({
- isPostMerge: true,
- });
- });
-
- it('renders pipeline', () => {
- expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true);
- expect(wrapper.find(MrWidgetPipeline).props()).toEqual(
- jasmine.objectContaining({
- pipeline: mockStore.mergePipeline,
- pipelineCoverageDelta: mockStore.pipelineCoverageDelta,
- ciStatus: mockStore.ciStatus,
- hasCi: mockStore.hasCI,
- sourceBranch: mockStore.targetBranch,
- sourceBranchLink: mockStore.targetBranch,
- }),
- );
- });
-
- it('renders deployments', () => {
- const expectedProps = mockStore.postMergeDeployments.map(dep =>
- jasmine.objectContaining({
- deployment: dep,
- showMetrics: true,
- }),
- );
-
- const deployments = wrapper.findAll('.mr-widget-extension .js-post-deployment');
-
- expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps);
- });
- });
-
- describe('with artifacts path', () => {
- it('renders the artifacts app', () => {
- expect(wrapper.find(ArtifactsApp).isVisible()).toBe(true);
- });
- });
-});
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
index 5997c93105e..883c41085fa 100644
--- a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
@@ -122,6 +122,19 @@ describe('MRWidgetPipeline', () => {
);
});
+ it('should render CI error when no CI is provided and pipeline must succeed is turned on', () => {
+ vm = mountComponent(Component, {
+ pipeline: {},
+ hasCi: false,
+ pipelineMustSucceed: true,
+ troubleshootingDocsPath: 'help',
+ });
+
+ expect(vm.$el.querySelector('.media-body').textContent.trim()).toContain(
+ 'No pipeline has been run for this commit.',
+ );
+ });
+
describe('with a pipeline', () => {
beforeEach(() => {
vm = mountComponent(Component, {
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
index d396f2d9271..9ba429c3d20 100644
--- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
@@ -18,6 +18,7 @@ const createTestMr = customConfig => {
isPipelineFailed: false,
isPipelinePassing: false,
isMergeAllowed: true,
+ isApproved: true,
onlyAllowMergeIfPipelineSucceeds: false,
ffOnlyEnabled: false,
hasCI: false,
@@ -919,8 +920,8 @@ describe('ReadyToMerge', () => {
});
});
- describe('Commit message area', () => {
- describe('when using merge commits', () => {
+ describe('Merge request project settings', () => {
+ describe('when the merge commit merge method is enabled', () => {
beforeEach(() => {
vm = createComponent({
mr: { ffOnlyEnabled: false },
@@ -936,7 +937,7 @@ describe('ReadyToMerge', () => {
});
});
- describe('when fast-forward merge is enabled', () => {
+ describe('when the fast-forward merge method is enabled', () => {
beforeEach(() => {
vm = createComponent({
mr: { ffOnlyEnabled: true },
diff --git a/spec/javascripts/vue_mr_widget/stores/artifacts_list/actions_spec.js b/spec/javascripts/vue_mr_widget/stores/artifacts_list/actions_spec.js
deleted file mode 100644
index 5070e74b5d2..00000000000
--- a/spec/javascripts/vue_mr_widget/stores/artifacts_list/actions_spec.js
+++ /dev/null
@@ -1,165 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import testAction from 'spec/helpers/vuex_action_helper';
-import { TEST_HOST } from 'spec/test_constants';
-import axios from '~/lib/utils/axios_utils';
-import {
- setEndpoint,
- requestArtifacts,
- clearEtagPoll,
- stopPolling,
- fetchArtifacts,
- receiveArtifactsSuccess,
- receiveArtifactsError,
-} from '~/vue_merge_request_widget/stores/artifacts_list/actions';
-import state from '~/vue_merge_request_widget/stores/artifacts_list/state';
-import * as types from '~/vue_merge_request_widget/stores/artifacts_list/mutation_types';
-
-describe('Artifacts App Store Actions', () => {
- let mockedState;
-
- beforeEach(() => {
- mockedState = state();
- });
-
- describe('setEndpoint', () => {
- it('should commit SET_ENDPOINT mutation', done => {
- testAction(
- setEndpoint,
- 'endpoint.json',
- mockedState,
- [{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }],
- [],
- done,
- );
- });
- });
-
- describe('requestArtifacts', () => {
- it('should commit REQUEST_ARTIFACTS mutation', done => {
- testAction(
- requestArtifacts,
- null,
- mockedState,
- [{ type: types.REQUEST_ARTIFACTS }],
- [],
- done,
- );
- });
- });
-
- describe('fetchArtifacts', () => {
- let mock;
-
- beforeEach(() => {
- mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- stopPolling();
- clearEtagPoll();
- });
-
- describe('success', () => {
- it('dispatches requestArtifacts and receiveArtifactsSuccess ', done => {
- mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, [
- {
- text: 'result.txt',
- url: 'asda',
- job_name: 'generate-artifact',
- job_path: 'asda',
- },
- ]);
-
- testAction(
- fetchArtifacts,
- null,
- mockedState,
- [],
- [
- {
- type: 'requestArtifacts',
- },
- {
- payload: {
- data: [
- {
- text: 'result.txt',
- url: 'asda',
- job_name: 'generate-artifact',
- job_path: 'asda',
- },
- ],
- status: 200,
- },
- type: 'receiveArtifactsSuccess',
- },
- ],
- done,
- );
- });
- });
-
- describe('error', () => {
- beforeEach(() => {
- mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
- });
-
- it('dispatches requestArtifacts and receiveArtifactsError ', done => {
- testAction(
- fetchArtifacts,
- null,
- mockedState,
- [],
- [
- {
- type: 'requestArtifacts',
- },
- {
- type: 'receiveArtifactsError',
- },
- ],
- done,
- );
- });
- });
- });
-
- describe('receiveArtifactsSuccess', () => {
- it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', done => {
- testAction(
- receiveArtifactsSuccess,
- { data: { summary: {} }, status: 200 },
- mockedState,
- [{ type: types.RECEIVE_ARTIFACTS_SUCCESS, payload: { summary: {} } }],
- [],
- done,
- );
- });
-
- it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', done => {
- testAction(
- receiveArtifactsSuccess,
- { data: { summary: {} }, status: 204 },
- mockedState,
- [],
- [],
- done,
- );
- });
- });
-
- describe('receiveArtifactsError', () => {
- it('should commit RECEIVE_ARTIFACTS_ERROR mutation', done => {
- testAction(
- receiveArtifactsError,
- null,
- mockedState,
- [{ type: types.RECEIVE_ARTIFACTS_ERROR }],
- [],
- done,
- );
- });
- });
-});
diff --git a/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js
deleted file mode 100644
index 1906585af7b..00000000000
--- a/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js
+++ /dev/null
@@ -1,112 +0,0 @@
-import MergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store';
-import { stateKey } from '~/vue_merge_request_widget/stores/state_maps';
-import mockData from '../mock_data';
-
-describe('MergeRequestStore', () => {
- let store;
-
- beforeEach(() => {
- store = new MergeRequestStore(mockData);
- });
-
- describe('setData', () => {
- it('should set isSHAMismatch when the diff SHA changes', () => {
- store.setData({ ...mockData, diff_head_sha: 'a-different-string' });
-
- expect(store.isSHAMismatch).toBe(true);
- });
-
- it('should not set isSHAMismatch when other data changes', () => {
- store.setData({ ...mockData, work_in_progress: !mockData.work_in_progress });
-
- expect(store.isSHAMismatch).toBe(false);
- });
-
- it('should update cached sha after rebasing', () => {
- store.setData({ ...mockData, diff_head_sha: 'abc123' }, true);
-
- expect(store.isSHAMismatch).toBe(false);
- expect(store.sha).toBe('abc123');
- });
-
- describe('isPipelinePassing', () => {
- it('is true when the CI status is `success`', () => {
- store.setData({ ...mockData, ci_status: 'success' });
-
- expect(store.isPipelinePassing).toBe(true);
- });
-
- it('is true when the CI status is `success-with-warnings`', () => {
- store.setData({ ...mockData, ci_status: 'success-with-warnings' });
-
- expect(store.isPipelinePassing).toBe(true);
- });
-
- it('is false when the CI status is `failed`', () => {
- store.setData({ ...mockData, ci_status: 'failed' });
-
- expect(store.isPipelinePassing).toBe(false);
- });
-
- it('is false when the CI status is anything except `success`', () => {
- store.setData({ ...mockData, ci_status: 'foobarbaz' });
-
- expect(store.isPipelinePassing).toBe(false);
- });
- });
-
- describe('isPipelineSkipped', () => {
- it('should set isPipelineSkipped=true when the CI status is `skipped`', () => {
- store.setData({ ...mockData, ci_status: 'skipped' });
-
- expect(store.isPipelineSkipped).toBe(true);
- });
-
- it('should set isPipelineSkipped=false when the CI status is anything except `skipped`', () => {
- store.setData({ ...mockData, ci_status: 'foobarbaz' });
-
- expect(store.isPipelineSkipped).toBe(false);
- });
- });
-
- describe('isNothingToMergeState', () => {
- it('returns true when nothingToMerge', () => {
- store.state = stateKey.nothingToMerge;
-
- expect(store.isNothingToMergeState).toEqual(true);
- });
-
- it('returns false when not nothingToMerge', () => {
- store.state = 'state';
-
- expect(store.isNothingToMergeState).toEqual(false);
- });
- });
- });
-
- describe('setPaths', () => {
- it('should set the add ci config path', () => {
- store.setData({ ...mockData });
-
- expect(store.mergeRequestAddCiConfigPath).toEqual('/group2/project2/new/pipeline');
- });
-
- it('should set humanAccess=Maintainer when user has that role', () => {
- store.setData({ ...mockData });
-
- expect(store.humanAccess).toEqual('Maintainer');
- });
-
- it('should set pipelinesEmptySvgPath', () => {
- store.setData({ ...mockData });
-
- expect(store.pipelinesEmptySvgPath).toBe('/path/to/svg');
- });
-
- it('should set newPipelinePath', () => {
- store.setData({ ...mockData });
-
- expect(store.newPipelinePath).toBe('/group2/project2/pipelines/new');
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/ci_badge_link_spec.js b/spec/javascripts/vue_shared/components/ci_badge_link_spec.js
deleted file mode 100644
index 367e07d3ad3..00000000000
--- a/spec/javascripts/vue_shared/components/ci_badge_link_spec.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import ciBadge from '~/vue_shared/components/ci_badge_link.vue';
-
-describe('CI Badge Link Component', () => {
- let CIBadge;
- let vm;
-
- const statuses = {
- canceled: {
- text: 'canceled',
- label: 'canceled',
- group: 'canceled',
- icon: 'status_canceled',
- details_path: 'status/canceled',
- },
- created: {
- text: 'created',
- label: 'created',
- group: 'created',
- icon: 'status_created',
- details_path: 'status/created',
- },
- failed: {
- text: 'failed',
- label: 'failed',
- group: 'failed',
- icon: 'status_failed',
- details_path: 'status/failed',
- },
- manual: {
- text: 'manual',
- label: 'manual action',
- group: 'manual',
- icon: 'status_manual',
- details_path: 'status/manual',
- },
- pending: {
- text: 'pending',
- label: 'pending',
- group: 'pending',
- icon: 'status_pending',
- details_path: 'status/pending',
- },
- running: {
- text: 'running',
- label: 'running',
- group: 'running',
- icon: 'status_running',
- details_path: 'status/running',
- },
- skipped: {
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- icon: 'status_skipped',
- details_path: 'status/skipped',
- },
- success_warining: {
- text: 'passed',
- label: 'passed',
- group: 'success-with-warnings',
- icon: 'status_warning',
- details_path: 'status/warning',
- },
- success: {
- text: 'passed',
- label: 'passed',
- group: 'passed',
- icon: 'status_success',
- details_path: 'status/passed',
- },
- };
-
- beforeEach(() => {
- CIBadge = Vue.extend(ciBadge);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('should render each status badge', () => {
- Object.keys(statuses).map(status => {
- vm = mountComponent(CIBadge, { status: statuses[status] });
-
- expect(vm.$el.getAttribute('href')).toEqual(statuses[status].details_path);
- expect(vm.$el.textContent.trim()).toEqual(statuses[status].text);
- expect(vm.$el.getAttribute('class')).toContain(`ci-status ci-${statuses[status].group}`);
- expect(vm.$el.querySelector('svg')).toBeDefined();
- return vm;
- });
- });
-
- it('should not render label', () => {
- vm = mountComponent(CIBadge, { status: statuses.canceled, showText: false });
-
- expect(vm.$el.textContent.trim()).toEqual('');
- });
-});
diff --git a/spec/javascripts/vue_shared/components/ci_icon_spec.js b/spec/javascripts/vue_shared/components/ci_icon_spec.js
deleted file mode 100644
index 9486d7d4f23..00000000000
--- a/spec/javascripts/vue_shared/components/ci_icon_spec.js
+++ /dev/null
@@ -1,122 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import ciIcon from '~/vue_shared/components/ci_icon.vue';
-
-describe('CI Icon component', () => {
- const Component = Vue.extend(ciIcon);
- let vm;
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('should render a span element with an svg', () => {
- vm = mountComponent(Component, {
- status: {
- icon: 'status_success',
- },
- });
-
- expect(vm.$el.tagName).toEqual('SPAN');
- expect(vm.$el.querySelector('span > svg')).toBeDefined();
- });
-
- it('should render a success status', () => {
- vm = mountComponent(Component, {
- status: {
- icon: 'status_success',
- group: 'success',
- },
- });
-
- expect(vm.$el.classList.contains('ci-status-icon-success')).toEqual(true);
- });
-
- it('should render a failed status', () => {
- vm = mountComponent(Component, {
- status: {
- icon: 'status_failed',
- group: 'failed',
- },
- });
-
- expect(vm.$el.classList.contains('ci-status-icon-failed')).toEqual(true);
- });
-
- it('should render success with warnings status', () => {
- vm = mountComponent(Component, {
- status: {
- icon: 'status_warning',
- group: 'warning',
- },
- });
-
- expect(vm.$el.classList.contains('ci-status-icon-warning')).toEqual(true);
- });
-
- it('should render pending status', () => {
- vm = mountComponent(Component, {
- status: {
- icon: 'status_pending',
- group: 'pending',
- },
- });
-
- expect(vm.$el.classList.contains('ci-status-icon-pending')).toEqual(true);
- });
-
- it('should render running status', () => {
- vm = mountComponent(Component, {
- status: {
- icon: 'status_running',
- group: 'running',
- },
- });
-
- expect(vm.$el.classList.contains('ci-status-icon-running')).toEqual(true);
- });
-
- it('should render created status', () => {
- vm = mountComponent(Component, {
- status: {
- icon: 'status_created',
- group: 'created',
- },
- });
-
- expect(vm.$el.classList.contains('ci-status-icon-created')).toEqual(true);
- });
-
- it('should render skipped status', () => {
- vm = mountComponent(Component, {
- status: {
- icon: 'status_skipped',
- group: 'skipped',
- },
- });
-
- expect(vm.$el.classList.contains('ci-status-icon-skipped')).toEqual(true);
- });
-
- it('should render canceled status', () => {
- vm = mountComponent(Component, {
- status: {
- icon: 'status_canceled',
- group: 'canceled',
- },
- });
-
- expect(vm.$el.classList.contains('ci-status-icon-canceled')).toEqual(true);
- });
-
- it('should render status for manual action', () => {
- vm = mountComponent(Component, {
- status: {
- icon: 'status_manual',
- group: 'manual',
- },
- });
-
- expect(vm.$el.classList.contains('ci-status-icon-manual')).toEqual(true);
- });
-});
diff --git a/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js
deleted file mode 100644
index fbe9337ecf4..00000000000
--- a/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js
+++ /dev/null
@@ -1,123 +0,0 @@
-import Vue from 'vue';
-import MockAdapter from 'axios-mock-adapter';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import waitForPromises from 'spec/helpers/wait_for_promises';
-import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants';
-import axios from '~/lib/utils/axios_utils';
-import contentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
-import '~/behaviors/markdown/render_gfm';
-
-describe('ContentViewer', () => {
- let vm;
- let mock;
-
- function createComponent(props) {
- const ContentViewer = Vue.extend(contentViewer);
- vm = mountComponent(ContentViewer, props);
- }
-
- afterEach(() => {
- vm.$destroy();
- if (mock) mock.restore();
- });
-
- it('markdown preview renders + loads rendered markdown from server', done => {
- mock = new MockAdapter(axios);
- mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(200, {
- body: '<b>testing</b>',
- });
-
- createComponent({
- path: 'test.md',
- content: '* Test',
- projectPath: 'testproject',
- type: 'markdown',
- });
-
- waitForPromises()
- .then(() => {
- expect(vm.$el.querySelector('.md-previewer').textContent).toContain('testing');
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('renders image preview', done => {
- createComponent({
- path: GREEN_BOX_IMAGE_URL,
- fileSize: 1024,
- type: 'image',
- });
-
- vm.$nextTick()
- .then(() => {
- expect(vm.$el.querySelector('img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('renders fallback download control', done => {
- createComponent({
- path: 'somepath/test.abc',
- fileSize: 1024,
- });
-
- vm.$nextTick()
- .then(() => {
- expect(
- vm.$el
- .querySelector('.file-info')
- .textContent.trim()
- .replace(/\s+/, ' '),
- ).toEqual('test.abc (1.00 KiB)');
-
- expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toEqual('Download');
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('renders fallback download control for file with a data URL path properly', done => {
- createComponent({
- path: 'data:application/octet-stream;base64,U0VMRUNUICfEhHNnc2cnIGZyb20gVGFibGVuYW1lOwoK',
- filePath: 'somepath/test.abc',
- });
-
- vm.$nextTick()
- .then(() => {
- expect(vm.$el.querySelector('.file-info').textContent.trim()).toEqual('test.abc');
- expect(vm.$el.querySelector('.btn.btn-default')).toHaveAttr('download', 'test.abc');
- expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toEqual('Download');
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('markdown preview receives the file path as a parameter', done => {
- mock = new MockAdapter(axios);
- spyOn(axios, 'post').and.callThrough();
- mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).reply(200, {
- body: '<b>testing</b>',
- });
-
- createComponent({
- path: 'test.md',
- content: '* Test',
- projectPath: 'testproject',
- type: 'markdown',
- filePath: 'foo/test.md',
- });
-
- vm.$nextTick()
- .then(() => {
- expect(axios.post).toHaveBeenCalledWith(
- `${gon.relative_url_root}/testproject/preview_markdown`,
- { path: 'foo/test.md', text: '* Test' },
- jasmine.any(Object),
- );
- })
- .then(done)
- .catch(done.fail);
- });
-});
diff --git a/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js
deleted file mode 100644
index 6a83790093a..00000000000
--- a/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import Vue from 'vue';
-
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants';
-import diffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
-
-describe('DiffViewer', () => {
- const requiredProps = {
- diffMode: 'replaced',
- diffViewerMode: 'image',
- newPath: GREEN_BOX_IMAGE_URL,
- newSha: 'ABC',
- oldPath: RED_BOX_IMAGE_URL,
- oldSha: 'DEF',
- };
- let vm;
-
- function createComponent(props) {
- const DiffViewer = Vue.extend(diffViewer);
-
- vm = mountComponent(DiffViewer, props);
- }
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders image diff', done => {
- window.gon = {
- relative_url_root: '',
- };
-
- createComponent(
- Object.assign({}, requiredProps, {
- projectPath: '',
- }),
- );
-
- setTimeout(() => {
- expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe(
- `//-/raw/DEF/${RED_BOX_IMAGE_URL}`,
- );
-
- expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe(
- `//-/raw/ABC/${GREEN_BOX_IMAGE_URL}`,
- );
-
- done();
- });
- });
-
- it('renders fallback download diff display', done => {
- createComponent(
- Object.assign({}, requiredProps, {
- diffViewerMode: 'added',
- newPath: 'test.abc',
- oldPath: 'testold.abc',
- }),
- );
-
- setTimeout(() => {
- expect(vm.$el.querySelector('.deleted .file-info').textContent.trim()).toContain(
- 'testold.abc',
- );
-
- expect(vm.$el.querySelector('.deleted .btn.btn-default').textContent.trim()).toContain(
- 'Download',
- );
-
- expect(vm.$el.querySelector('.added .file-info').textContent.trim()).toContain('test.abc');
- expect(vm.$el.querySelector('.added .btn.btn-default').textContent.trim()).toContain(
- 'Download',
- );
-
- done();
- });
- });
-
- it('renders renamed component', () => {
- createComponent(
- Object.assign({}, requiredProps, {
- diffMode: 'renamed',
- diffViewerMode: 'renamed',
- newPath: 'test.abc',
- oldPath: 'testold.abc',
- }),
- );
-
- expect(vm.$el.textContent).toContain('File moved');
- });
-
- it('renders mode changed component', () => {
- createComponent(
- Object.assign({}, requiredProps, {
- diffMode: 'mode_changed',
- newPath: 'test.abc',
- oldPath: 'testold.abc',
- aMode: '123',
- bMode: '321',
- }),
- );
-
- expect(vm.$el.textContent).toContain('File mode changed from 123 to 321');
- });
-});
diff --git a/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js
deleted file mode 100644
index b00fa785a0e..00000000000
--- a/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import Vue from 'vue';
-
-import { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper';
-import dropdownButtonComponent from '~/vue_shared/components/dropdown/dropdown_button.vue';
-
-const defaultLabel = 'Select';
-const customLabel = 'Select project';
-
-const createComponent = (props, slots = {}) => {
- const Component = Vue.extend(dropdownButtonComponent);
-
- return mountComponentWithSlots(Component, { props, slots });
-};
-
-describe('DropdownButtonComponent', () => {
- let vm;
-
- beforeEach(() => {
- vm = createComponent();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('computed', () => {
- describe('dropdownToggleText', () => {
- it('returns default toggle text', () => {
- expect(vm.toggleText).toBe(defaultLabel);
- });
-
- it('returns custom toggle text when provided via props', () => {
- const vmEmptyLabels = createComponent({ toggleText: customLabel });
-
- expect(vmEmptyLabels.toggleText).toBe(customLabel);
- vmEmptyLabels.$destroy();
- });
- });
- });
-
- describe('template', () => {
- it('renders component container element of type `button`', () => {
- expect(vm.$el.nodeName).toBe('BUTTON');
- });
-
- it('renders component container element with required data attributes', () => {
- expect(vm.$el.dataset.abilityName).toBe(vm.abilityName);
- expect(vm.$el.dataset.fieldName).toBe(vm.fieldName);
- expect(vm.$el.dataset.issueUpdate).toBe(vm.updatePath);
- expect(vm.$el.dataset.labels).toBe(vm.labelsPath);
- expect(vm.$el.dataset.namespacePath).toBe(vm.namespace);
- expect(vm.$el.dataset.showAny).not.toBeDefined();
- });
-
- it('renders dropdown toggle text element', () => {
- const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text');
-
- expect(dropdownToggleTextEl).not.toBeNull();
- expect(dropdownToggleTextEl.innerText.trim()).toBe(defaultLabel);
- });
-
- it('renders dropdown button icon', () => {
- const dropdownIconEl = vm.$el.querySelector('.dropdown-toggle-icon i.fa');
-
- expect(dropdownIconEl).not.toBeNull();
- expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true);
- });
-
- it('renders slot, if default slot exists', () => {
- vm = createComponent(
- {},
- {
- default: ['Lorem Ipsum Dolar'],
- },
- );
-
- expect(vm.$el).not.toContainElement('.dropdown-toggle-text');
- expect(vm.$el).toHaveText('Lorem Ipsum Dolar');
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/dropdown/dropdown_hidden_input_spec.js b/spec/javascripts/vue_shared/components/dropdown/dropdown_hidden_input_spec.js
deleted file mode 100644
index 402de2a8788..00000000000
--- a/spec/javascripts/vue_shared/components/dropdown/dropdown_hidden_input_spec.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import Vue from 'vue';
-
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import dropdownHiddenInputComponent from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
-
-import { mockLabels } from './mock_data';
-
-const createComponent = (name = 'label_id[]', value = mockLabels[0].id) => {
- const Component = Vue.extend(dropdownHiddenInputComponent);
-
- return mountComponent(Component, {
- name,
- value,
- });
-};
-
-describe('DropdownHiddenInputComponent', () => {
- let vm;
-
- beforeEach(() => {
- vm = createComponent();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('template', () => {
- it('renders input element of type `hidden`', () => {
- expect(vm.$el.nodeName).toBe('INPUT');
- expect(vm.$el.getAttribute('type')).toBe('hidden');
- expect(vm.$el.getAttribute('name')).toBe(vm.name);
- expect(vm.$el.getAttribute('value')).toBe(`${vm.value}`);
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/file_finder/item_spec.js b/spec/javascripts/vue_shared/components/file_finder/item_spec.js
deleted file mode 100644
index e18d0a46223..00000000000
--- a/spec/javascripts/vue_shared/components/file_finder/item_spec.js
+++ /dev/null
@@ -1,140 +0,0 @@
-import Vue from 'vue';
-import { file } from 'spec/ide/helpers';
-import ItemComponent from '~/vue_shared/components/file_finder/item.vue';
-import createComponent from '../../../helpers/vue_mount_component_helper';
-
-describe('File finder item spec', () => {
- const Component = Vue.extend(ItemComponent);
- let vm;
- let localFile;
-
- beforeEach(() => {
- localFile = {
- ...file(),
- name: 'test file',
- path: 'test/file',
- };
-
- vm = createComponent(Component, {
- file: localFile,
- focused: true,
- searchText: '',
- index: 0,
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders file name & path', () => {
- expect(vm.$el.textContent).toContain('test file');
- expect(vm.$el.textContent).toContain('test/file');
- });
-
- describe('focused', () => {
- it('adds is-focused class', () => {
- expect(vm.$el.classList).toContain('is-focused');
- });
-
- it('does not have is-focused class when not focused', done => {
- vm.focused = false;
-
- vm.$nextTick(() => {
- expect(vm.$el.classList).not.toContain('is-focused');
-
- done();
- });
- });
- });
-
- describe('changed file icon', () => {
- it('does not render when not a changed or temp file', () => {
- expect(vm.$el.querySelector('.diff-changed-stats')).toBe(null);
- });
-
- it('renders when a changed file', done => {
- vm.file.changed = true;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null);
-
- done();
- });
- });
-
- it('renders when a temp file', done => {
- vm.file.tempFile = true;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null);
-
- done();
- });
- });
- });
-
- it('emits event when clicked', () => {
- spyOn(vm, '$emit');
-
- vm.$el.click();
-
- expect(vm.$emit).toHaveBeenCalledWith('click', vm.file);
- });
-
- describe('path', () => {
- let el;
-
- beforeEach(done => {
- vm.searchText = 'file';
-
- el = vm.$el.querySelector('.diff-changed-file-path');
-
- vm.$nextTick(done);
- });
-
- it('highlights text', () => {
- expect(el.querySelectorAll('.highlighted').length).toBe(4);
- });
-
- it('adds ellipsis to long text', done => {
- vm.file.path = new Array(70)
- .fill()
- .map((_, i) => `${i}-`)
- .join('');
-
- vm.$nextTick(() => {
- expect(el.textContent).toBe(`...${vm.file.path.substr(vm.file.path.length - 60)}`);
- done();
- });
- });
- });
-
- describe('name', () => {
- let el;
-
- beforeEach(done => {
- vm.searchText = 'file';
-
- el = vm.$el.querySelector('.diff-changed-file-name');
-
- vm.$nextTick(done);
- });
-
- it('highlights text', () => {
- expect(el.querySelectorAll('.highlighted').length).toBe(4);
- });
-
- it('does not add ellipsis to long text', done => {
- vm.file.name = new Array(70)
- .fill()
- .map((_, i) => `${i}-`)
- .join('');
-
- vm.$nextTick(() => {
- expect(el.textContent).not.toBe(`...${vm.file.name.substr(vm.file.name.length - 60)}`);
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/filtered_search_dropdown_spec.js b/spec/javascripts/vue_shared/components/filtered_search_dropdown_spec.js
deleted file mode 100644
index 0bb4a04557b..00000000000
--- a/spec/javascripts/vue_shared/components/filtered_search_dropdown_spec.js
+++ /dev/null
@@ -1,190 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/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 => {
- spyOn(vm, '$emit');
- 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/javascripts/vue_shared/components/gl_countdown_spec.js b/spec/javascripts/vue_shared/components/gl_countdown_spec.js
deleted file mode 100644
index 929ffe219f4..00000000000
--- a/spec/javascripts/vue_shared/components/gl_countdown_spec.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import Vue from 'vue';
-import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
-
-describe('GlCountdown', () => {
- const Component = Vue.extend(GlCountdown);
- let vm;
- let now = '2000-01-01T00:00:00Z';
-
- beforeEach(() => {
- spyOn(Date, 'now').and.callFake(() => new Date(now).getTime());
- jasmine.clock().install();
- });
-
- afterEach(() => {
- vm.$destroy();
- jasmine.clock().uninstall();
- });
-
- describe('when there is time remaining', () => {
- beforeEach(done => {
- vm = mountComponent(Component, {
- endDateString: '2000-01-01T01:02:03Z',
- });
-
- Vue.nextTick()
- .then(done)
- .catch(done.fail);
- });
-
- it('displays remaining time', () => {
- expect(vm.$el).toContainText('01:02:03');
- });
-
- it('updates remaining time', done => {
- now = '2000-01-01T00:00:01Z';
- jasmine.clock().tick(1000);
-
- Vue.nextTick()
- .then(() => {
- expect(vm.$el).toContainText('01:02:02');
- done();
- })
- .catch(done.fail);
- });
- });
-
- describe('when there is no time remaining', () => {
- beforeEach(done => {
- vm = mountComponent(Component, {
- endDateString: '1900-01-01T00:00:00Z',
- });
-
- Vue.nextTick()
- .then(done)
- .catch(done.fail);
- });
-
- it('displays 00:00:00', () => {
- expect(vm.$el).toContainText('00:00:00');
- });
- });
-
- describe('when an invalid date is passed', () => {
- it('throws a validation error', () => {
- spyOn(Vue.config, 'warnHandler').and.stub();
- vm = mountComponent(Component, {
- endDateString: 'this is invalid',
- });
-
- expect(Vue.config.warnHandler).toHaveBeenCalledTimes(1);
- const [errorMessage] = Vue.config.warnHandler.calls.argsFor(0);
-
- expect(errorMessage).toMatch(/^Invalid prop: .* "endDateString"/);
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/header_ci_component_spec.js b/spec/javascripts/vue_shared/components/header_ci_component_spec.js
deleted file mode 100644
index b1abc972e1d..00000000000
--- a/spec/javascripts/vue_shared/components/header_ci_component_spec.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import Vue from 'vue';
-import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper';
-import headerCi from '~/vue_shared/components/header_ci_component.vue';
-
-describe('Header CI Component', () => {
- let HeaderCi;
- let vm;
- let props;
-
- beforeEach(() => {
- HeaderCi = Vue.extend(headerCi);
- props = {
- status: {
- group: 'failed',
- icon: 'status_failed',
- label: 'failed',
- text: 'failed',
- details_path: 'path',
- },
- itemName: 'job',
- itemId: 123,
- time: '2017-05-08T14:57:39.781Z',
- user: {
- web_url: 'path',
- name: 'Foo',
- username: 'foobar',
- email: 'foo@bar.com',
- avatar_url: 'link',
- },
- hasSidebarButton: true,
- };
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- const findActionButtons = () => vm.$el.querySelector('.header-action-buttons');
-
- describe('render', () => {
- beforeEach(() => {
- vm = mountComponent(HeaderCi, props);
- });
-
- it('should render status badge', () => {
- expect(vm.$el.querySelector('.ci-failed')).toBeDefined();
- expect(vm.$el.querySelector('.ci-status-icon-failed svg')).toBeDefined();
- expect(vm.$el.querySelector('.ci-failed').getAttribute('href')).toEqual(
- props.status.details_path,
- );
- });
-
- it('should render item name and id', () => {
- expect(vm.$el.querySelector('strong').textContent.trim()).toEqual('job #123');
- });
-
- it('should render timeago date', () => {
- expect(vm.$el.querySelector('time')).toBeDefined();
- });
-
- it('should render user icon and name', () => {
- expect(vm.$el.querySelector('.js-user-link').innerText.trim()).toContain(props.user.name);
- });
-
- it('should render sidebar toggle button', () => {
- expect(vm.$el.querySelector('.js-sidebar-build-toggle')).not.toBeNull();
- });
-
- it('should not render header action buttons when empty', () => {
- expect(findActionButtons()).toBeNull();
- });
- });
-
- describe('slot', () => {
- it('should render header action buttons', () => {
- vm = mountComponentWithSlots(HeaderCi, { props, slots: { default: 'Test Actions' } });
-
- const buttons = findActionButtons();
-
- expect(buttons).not.toBeNull();
- expect(buttons.textContent).toEqual('Test Actions');
- });
- });
-
- describe('shouldRenderTriggeredLabel', () => {
- it('should rendered created keyword when the shouldRenderTriggeredLabel is false', () => {
- vm = mountComponent(HeaderCi, { ...props, shouldRenderTriggeredLabel: false });
-
- expect(vm.$el.textContent).toContain('created');
- expect(vm.$el.textContent).not.toContain('triggered');
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js b/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js
deleted file mode 100644
index b7de40b4831..00000000000
--- a/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import Vue from 'vue';
-import SuggestionsComponent from '~/vue_shared/components/markdown/suggestions.vue';
-
-const MOCK_DATA = {
- suggestions: [
- {
- id: 1,
- appliable: true,
- applied: false,
- current_user: {
- can_apply: true,
- },
- diff_lines: [
- {
- can_receive_suggestion: false,
- line_code: null,
- meta_data: null,
- new_line: null,
- old_line: 5,
- rich_text: '-test',
- text: '-test',
- type: 'old',
- },
- {
- can_receive_suggestion: true,
- line_code: null,
- meta_data: null,
- new_line: 5,
- old_line: null,
- rich_text: '+new test',
- text: '+new test',
- type: 'new',
- },
- ],
- },
- ],
- noteHtml: `
- <div class="suggestion">
- <div class="line">-oldtest</div>
- </div>
- <div class="suggestion">
- <div class="line">+newtest</div>
- </div>
- `,
- isApplied: false,
- helpPagePath: 'path_to_docs',
-};
-
-describe('Suggestion component', () => {
- let vm;
- let diffTable;
-
- beforeEach(done => {
- const Component = Vue.extend(SuggestionsComponent);
-
- vm = new Component({
- propsData: MOCK_DATA,
- }).$mount();
-
- diffTable = vm.generateDiff(0).$mount().$el;
-
- spyOn(vm, 'renderSuggestions');
- vm.renderSuggestions();
- Vue.nextTick(done);
- });
-
- describe('mounted', () => {
- it('renders a flash container', () => {
- expect(vm.$el.querySelector('.js-suggestions-flash')).not.toBeNull();
- });
-
- it('renders a container for suggestions', () => {
- expect(vm.$refs.container).not.toBeNull();
- });
-
- it('renders suggestions', () => {
- expect(vm.renderSuggestions).toHaveBeenCalled();
- expect(vm.$el.innerHTML.includes('oldtest')).toBe(true);
- expect(vm.$el.innerHTML.includes('newtest')).toBe(true);
- });
- });
-
- describe('generateDiff', () => {
- it('generates a diff table', () => {
- expect(diffTable.querySelector('.md-suggestion-diff')).not.toBeNull();
- });
-
- it('generates a diff table that contains contents of `oldLineContent`', () => {
- expect(diffTable.innerHTML.includes(vm.fromContent)).toBe(true);
- });
-
- it('generates a diff table that contains contents the suggested lines', () => {
- MOCK_DATA.suggestions[0].diff_lines.forEach(line => {
- const text = line.text.substring(1);
-
- expect(diffTable.innerHTML.includes(text)).toBe(true);
- });
- });
-
- it('generates a diff table with the correct line number for each suggested line', () => {
- const lines = diffTable.querySelectorAll('.old_line');
-
- expect(parseInt([...lines][0].innerHTML, 10)).toBe(5);
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js b/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js
deleted file mode 100644
index 288eb40cc76..00000000000
--- a/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import toolbar from '~/vue_shared/components/markdown/toolbar.vue';
-
-describe('toolbar', () => {
- let vm;
- const Toolbar = Vue.extend(toolbar);
- const props = {
- markdownDocsPath: '',
- };
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('user can attach file', () => {
- beforeEach(() => {
- vm = mountComponent(Toolbar, props);
- });
-
- it('should render uploading-container', () => {
- expect(vm.$el.querySelector('.uploading-container')).not.toBeNull();
- });
- });
-
- describe('user cannot attach file', () => {
- beforeEach(() => {
- vm = mountComponent(
- Toolbar,
- Object.assign({}, props, {
- canAttachFile: false,
- }),
- );
- });
-
- it('should not render uploading-container', () => {
- expect(vm.$el.querySelector('.uploading-container')).toBeNull();
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/navigation_tabs_spec.js b/spec/javascripts/vue_shared/components/navigation_tabs_spec.js
deleted file mode 100644
index beb980a6556..00000000000
--- a/spec/javascripts/vue_shared/components/navigation_tabs_spec.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import navigationTabs from '~/vue_shared/components/navigation_tabs.vue';
-
-describe('navigation tabs component', () => {
- let vm;
- let Component;
- let data;
-
- beforeEach(() => {
- data = [
- {
- name: 'All',
- scope: 'all',
- count: 1,
- isActive: true,
- },
- {
- name: 'Pending',
- scope: 'pending',
- count: 0,
- isActive: false,
- },
- {
- name: 'Running',
- scope: 'running',
- isActive: false,
- },
- ];
-
- Component = Vue.extend(navigationTabs);
- vm = mountComponent(Component, { tabs: data, scope: 'pipelines' });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('should render tabs', () => {
- expect(vm.$el.querySelectorAll('li').length).toEqual(data.length);
- });
-
- it('should render active tab', () => {
- expect(vm.$el.querySelector('.active .js-pipelines-tab-all')).toBeDefined();
- });
-
- it('should render badge', () => {
- expect(vm.$el.querySelector('.js-pipelines-tab-all .badge').textContent.trim()).toEqual('1');
- expect(vm.$el.querySelector('.js-pipelines-tab-pending .badge').textContent.trim()).toEqual(
- '0',
- );
- });
-
- it('should not render badge', () => {
- expect(vm.$el.querySelector('.js-pipelines-tab-running .badge')).toEqual(null);
- });
-
- it('should trigger onTabClick', () => {
- spyOn(vm, '$emit');
- vm.$el.querySelector('.js-pipelines-tab-pending').click();
-
- expect(vm.$emit).toHaveBeenCalledWith('onChangeTab', 'pending');
- });
-});
diff --git a/spec/javascripts/vue_shared/components/pikaday_spec.js b/spec/javascripts/vue_shared/components/pikaday_spec.js
deleted file mode 100644
index b787ba7596f..00000000000
--- a/spec/javascripts/vue_shared/components/pikaday_spec.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import datePicker from '~/vue_shared/components/pikaday.vue';
-
-describe('datePicker', () => {
- let vm;
- beforeEach(() => {
- const DatePicker = Vue.extend(datePicker);
- vm = mountComponent(DatePicker, {
- label: 'label',
- });
- });
-
- it('should render label text', () => {
- expect(vm.$el.querySelector('.dropdown-toggle-text').innerText.trim()).toEqual('label');
- });
-
- it('should show calendar', () => {
- expect(vm.$el.querySelector('.pika-single')).toBeDefined();
- });
-
- it('should toggle when dropdown is clicked', () => {
- const hidePicker = jasmine.createSpy();
- vm.$on('hidePicker', hidePicker);
-
- vm.$el.querySelector('.dropdown-menu-toggle').click();
-
- expect(hidePicker).toHaveBeenCalled();
- });
-});
diff --git a/spec/javascripts/vue_shared/components/project_avatar/default_spec.js b/spec/javascripts/vue_shared/components/project_avatar/default_spec.js
deleted file mode 100644
index 2ec19ebf80e..00000000000
--- a/spec/javascripts/vue_shared/components/project_avatar/default_spec.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { projectData } from 'spec/ide/mock_data';
-import { TEST_HOST } from 'spec/test_constants';
-import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility';
-import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue';
-
-describe('ProjectAvatarDefault component', () => {
- const Component = Vue.extend(ProjectAvatarDefault);
- let vm;
-
- beforeEach(() => {
- vm = mountComponent(Component, {
- project: projectData,
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders identicon if project has no avatar_url', done => {
- const expectedText = getFirstCharacterCapitalized(projectData.name);
-
- vm.project = {
- ...vm.project,
- avatar_url: null,
- };
-
- vm.$nextTick()
- .then(() => {
- const identiconEl = vm.$el.querySelector('.identicon');
-
- expect(identiconEl).not.toBe(null);
- expect(identiconEl.textContent.trim()).toEqual(expectedText);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('renders avatar image if project has avatar_url', done => {
- const avatarUrl = `${TEST_HOST}/images/home/nasa.svg`;
-
- vm.project = {
- ...vm.project,
- avatar_url: avatarUrl,
- };
-
- vm.$nextTick()
- .then(() => {
- expect(vm.$el).toContainElement('.avatar');
- expect(vm.$el).not.toContainElement('.identicon');
- expect(vm.$el.querySelector('img')).toHaveAttr('src', avatarUrl);
- })
- .then(done)
- .catch(done.fail);
- });
-});
diff --git a/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js b/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js
deleted file mode 100644
index e73fb97b741..00000000000
--- a/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js
+++ /dev/null
@@ -1,109 +0,0 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { trimText } from 'spec/helpers/text_helper';
-import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
-
-const localVue = createLocalVue();
-
-describe('ProjectListItem component', () => {
- const Component = localVue.extend(ProjectListItem);
- let wrapper;
- let vm;
- let options;
- loadJSONFixtures('static/projects.json');
- const project = getJSONFixture('static/projects.json')[0];
-
- beforeEach(() => {
- options = {
- propsData: {
- project,
- selected: false,
- },
- localVue,
- };
- });
-
- afterEach(() => {
- wrapper.vm.$destroy();
- });
-
- 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);
- });
-
- it('renders a check mark icon if selected === true', () => {
- options.propsData.selected = true;
-
- wrapper = shallowMount(Component, options);
-
- expect(wrapper.contains('.js-selected-icon.js-selected')).toBe(true);
- });
-
- it(`emits a "clicked" event when clicked`, () => {
- wrapper = shallowMount(Component, options);
- ({ vm } = wrapper);
-
- spyOn(vm, '$emit');
- wrapper.vm.onClick();
-
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('click');
- });
-
- it(`renders the project avatar`, () => {
- wrapper = shallowMount(Component, options);
-
- expect(wrapper.contains('.js-project-avatar')).toBe(true);
- });
-
- it(`renders a simple namespace name with a trailing slash`, () => {
- options.propsData.project.name_with_namespace = 'a / b';
-
- wrapper = shallowMount(Component, options);
- const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
-
- expect(renderedNamespace).toBe('a /');
- });
-
- it(`renders a properly truncated namespace with a trailing slash`, () => {
- options.propsData.project.name_with_namespace = 'a / b / c / d / e / f';
-
- wrapper = shallowMount(Component, options);
- const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
-
- expect(renderedNamespace).toBe('a / ... / e /');
- });
-
- it(`renders the project name`, () => {
- options.propsData.project.name = 'my-test-project';
-
- wrapper = shallowMount(Component, options);
- const renderedName = trimText(wrapper.find('.js-project-name').text());
-
- expect(renderedName).toBe('my-test-project');
- });
-
- it(`renders the project name with highlighting in the case of a search query match`, () => {
- options.propsData.project.name = 'my-test-project';
- options.propsData.matcher = 'pro';
-
- wrapper = shallowMount(Component, options);
- const renderedName = trimText(wrapper.find('.js-project-name').html());
- const expected = 'my-test-<b>p</b><b>r</b><b>o</b>ject';
-
- expect(renderedName).toContain(expected);
- });
-
- it('prevents search query and project name XSS', () => {
- const alertSpy = spyOn(window, 'alert');
- options.propsData.project.name = "my-xss-pro<script>alert('XSS');</script>ject";
- options.propsData.matcher = "pro<script>alert('XSS');</script>";
-
- wrapper = shallowMount(Component, options);
- const renderedName = trimText(wrapper.find('.js-project-name').html());
- const expected = 'my-xss-project';
-
- expect(renderedName).toContain(expected);
- expect(alertSpy).not.toHaveBeenCalled();
- });
-});
diff --git a/spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js b/spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js
deleted file mode 100644
index 5d995f06abb..00000000000
--- a/spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js
+++ /dev/null
@@ -1,142 +0,0 @@
-import Vue from 'vue';
-import { head } from 'lodash';
-
-import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui';
-import { mount, createLocalVue } from '@vue/test-utils';
-import { trimText } from 'spec/helpers/text_helper';
-import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
-import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
-
-const localVue = createLocalVue();
-
-describe('ProjectSelector component', () => {
- let wrapper;
- let vm;
- loadJSONFixtures('static/projects.json');
- const allProjects = getJSONFixture('static/projects.json');
- const searchResults = allProjects.slice(0, 5);
- let selected = [];
- selected = selected.concat(allProjects.slice(0, 3)).concat(allProjects.slice(5, 8));
-
- const findSearchInput = () => wrapper.find(GlSearchBoxByType).find('input');
-
- beforeEach(() => {
- jasmine.clock().install();
- jasmine.clock().mockDate();
-
- wrapper = mount(Vue.extend(ProjectSelector), {
- localVue,
- propsData: {
- projectSearchResults: searchResults,
- selectedProjects: selected,
- showNoResultsMessage: false,
- showMinimumSearchQueryMessage: false,
- showLoadingIndicator: false,
- showSearchErrorMessage: false,
- },
- attachToDocument: true,
- });
-
- ({ vm } = wrapper);
- });
-
- afterEach(() => {
- jasmine.clock().uninstall();
- vm.$destroy();
- });
-
- it('renders the search results', () => {
- expect(wrapper.findAll('.js-project-list-item').length).toBe(5);
- });
-
- it(`triggers a (debounced) search when the search input value changes`, () => {
- spyOn(vm, '$emit');
- const query = 'my test query!';
- const searchInput = findSearchInput();
-
- searchInput.setValue(query);
- searchInput.trigger('input');
-
- expect(vm.$emit).not.toHaveBeenCalledWith();
- jasmine.clock().tick(501);
-
- expect(vm.$emit).toHaveBeenCalledWith('searched', query);
- });
-
- it(`debounces the search input`, () => {
- spyOn(vm, '$emit');
- const searchInput = findSearchInput();
-
- const updateSearchQuery = (count = 0) => {
- if (count === 10) {
- jasmine.clock().tick(101);
-
- expect(vm.$emit).toHaveBeenCalledTimes(1);
- expect(vm.$emit).toHaveBeenCalledWith('searched', `search query #9`);
- } else {
- searchInput.setValue(`search query #${count}`);
- searchInput.trigger('input');
-
- jasmine.clock().tick(400);
- updateSearchQuery(count + 1);
- }
- };
-
- updateSearchQuery();
- });
-
- it(`includes a placeholder in the search box`, () => {
- const searchInput = findSearchInput();
-
- expect(searchInput.attributes('placeholder')).toBe('Search your projects');
- });
-
- it(`triggers a "bottomReached" event when user has scrolled to the bottom of the list`, () => {
- spyOn(vm, '$emit');
- wrapper.find(GlInfiniteScroll).vm.$emit('bottomReached');
-
- expect(vm.$emit).toHaveBeenCalledWith('bottomReached');
- });
-
- it(`triggers a "projectClicked" event when a project is clicked`, () => {
- spyOn(vm, '$emit');
- wrapper.find(ProjectListItem).vm.$emit('click', head(searchResults));
-
- expect(vm.$emit).toHaveBeenCalledWith('projectClicked', head(searchResults));
- });
-
- it(`shows a "no results" message if showNoResultsMessage === true`, () => {
- wrapper.setProps({ showNoResultsMessage: true });
-
- return vm.$nextTick().then(() => {
- const noResultsEl = wrapper.find('.js-no-results-message');
-
- expect(noResultsEl.exists()).toBe(true);
- expect(trimText(noResultsEl.text())).toEqual('Sorry, no projects matched your search');
- });
- });
-
- it(`shows a "minimum search query" message if showMinimumSearchQueryMessage === true`, () => {
- wrapper.setProps({ showMinimumSearchQueryMessage: true });
-
- return vm.$nextTick().then(() => {
- const minimumSearchEl = wrapper.find('.js-minimum-search-query-message');
-
- expect(minimumSearchEl.exists()).toBe(true);
- expect(trimText(minimumSearchEl.text())).toEqual('Enter at least three characters to search');
- });
- });
-
- it(`shows a error message if showSearchErrorMessage === true`, () => {
- wrapper.setProps({ showSearchErrorMessage: true });
-
- return vm.$nextTick().then(() => {
- const errorMessageEl = wrapper.find('.js-search-error-message');
-
- expect(errorMessageEl.exists()).toBe(true);
- expect(trimText(errorMessageEl.text())).toEqual(
- 'Something went wrong, unable to search projects',
- );
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/stacked_progress_bar_spec.js b/spec/javascripts/vue_shared/components/stacked_progress_bar_spec.js
deleted file mode 100644
index c062ee13231..00000000000
--- a/spec/javascripts/vue_shared/components/stacked_progress_bar_spec.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import Vue from 'vue';
-
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import stackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue';
-
-const createComponent = config => {
- const Component = Vue.extend(stackedProgressBarComponent);
- const defaultConfig = Object.assign(
- {},
- {
- successLabel: 'Synced',
- failureLabel: 'Failed',
- neutralLabel: 'Out of sync',
- successCount: 25,
- failureCount: 10,
- totalCount: 5000,
- },
- config,
- );
-
- return mountComponent(Component, defaultConfig);
-};
-
-describe('StackedProgressBarComponent', () => {
- let vm;
-
- beforeEach(() => {
- vm = createComponent();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('computed', () => {
- describe('neutralCount', () => {
- it('returns neutralCount based on totalCount, successCount and failureCount', () => {
- expect(vm.neutralCount).toBe(4965); // 5000 - 25 - 10
- });
- });
- });
-
- describe('methods', () => {
- describe('getPercent', () => {
- it('returns percentage from provided count based on `totalCount`', () => {
- expect(vm.getPercent(500)).toBe(10);
- });
-
- it('returns percentage with decimal place from provided count based on `totalCount`', () => {
- expect(vm.getPercent(67)).toBe(1.3);
- });
-
- it('returns percentage as `< 1` from provided count based on `totalCount` when evaluated value is less than 1', () => {
- expect(vm.getPercent(10)).toBe('< 1');
- });
-
- it('returns 0 if totalCount is falsy', () => {
- vm = createComponent({ totalCount: 0 });
-
- expect(vm.getPercent(100)).toBe(0);
- });
- });
-
- describe('barStyle', () => {
- it('returns style string based on percentage provided', () => {
- expect(vm.barStyle(50)).toBe('width: 50%;');
- });
- });
-
- describe('getTooltip', () => {
- describe('when hideTooltips is false', () => {
- it('returns label string based on label and count provided', () => {
- expect(vm.getTooltip('Synced', 10)).toBe('Synced: 10');
- });
- });
-
- describe('when hideTooltips is true', () => {
- beforeEach(() => {
- vm = createComponent({ hideTooltips: true });
- });
-
- it('returns an empty string', () => {
- expect(vm.getTooltip('Synced', 10)).toBe('');
- });
- });
- });
- });
-
- describe('template', () => {
- it('renders container element', () => {
- expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy();
- });
-
- it('renders empty state when count is unavailable', () => {
- const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 });
-
- expect(vmX.$el.querySelectorAll('.status-unavailable').length).not.toBe(0);
- vmX.$destroy();
- });
-
- it('renders bar elements when count is available', () => {
- expect(vm.$el.querySelectorAll('.status-green').length).not.toBe(0);
- expect(vm.$el.querySelectorAll('.status-neutral').length).not.toBe(0);
- expect(vm.$el.querySelectorAll('.status-red').length).not.toBe(0);
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/tabs/tab_spec.js b/spec/javascripts/vue_shared/components/tabs/tab_spec.js
deleted file mode 100644
index 8437fe37738..00000000000
--- a/spec/javascripts/vue_shared/components/tabs/tab_spec.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import Tab from '~/vue_shared/components/tabs/tab.vue';
-
-describe('Tab component', () => {
- const Component = Vue.extend(Tab);
- let vm;
-
- beforeEach(() => {
- vm = mountComponent(Component);
- });
-
- it('sets localActive to equal active', done => {
- vm.active = true;
-
- vm.$nextTick(() => {
- expect(vm.localActive).toBe(true);
-
- done();
- });
- });
-
- it('sets active class', done => {
- vm.active = true;
-
- vm.$nextTick(() => {
- expect(vm.$el.classList).toContain('active');
-
- done();
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/tabs/tabs_spec.js b/spec/javascripts/vue_shared/components/tabs/tabs_spec.js
deleted file mode 100644
index 50ba18cd338..00000000000
--- a/spec/javascripts/vue_shared/components/tabs/tabs_spec.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import Vue from 'vue';
-import Tabs from '~/vue_shared/components/tabs/tabs';
-import Tab from '~/vue_shared/components/tabs/tab.vue';
-
-describe('Tabs component', () => {
- let vm;
-
- beforeEach(done => {
- vm = new Vue({
- components: {
- Tabs,
- Tab,
- },
- template: `
- <div>
- <tabs>
- <tab title="Testing" active>
- First tab
- </tab>
- <tab>
- <template slot="title">Test slot</template>
- Second tab
- </tab>
- </tabs>
- </div>
- `,
- }).$mount();
-
- setTimeout(done);
- });
-
- describe('tab links', () => {
- it('renders links for tabs', () => {
- expect(vm.$el.querySelectorAll('a').length).toBe(2);
- });
-
- it('renders link titles from props', () => {
- expect(vm.$el.querySelector('a').textContent).toContain('Testing');
- });
-
- it('renders link titles from slot', () => {
- expect(vm.$el.querySelectorAll('a')[1].textContent).toContain('Test slot');
- });
-
- it('renders active class', () => {
- expect(vm.$el.querySelector('a').classList).toContain('active');
- });
-
- it('updates active class on click', done => {
- vm.$el.querySelectorAll('a')[1].click();
-
- setTimeout(() => {
- expect(vm.$el.querySelector('a').classList).not.toContain('active');
- expect(vm.$el.querySelectorAll('a')[1].classList).toContain('active');
-
- done();
- });
- });
- });
-
- describe('content', () => {
- it('renders content panes', () => {
- expect(vm.$el.querySelectorAll('.tab-pane').length).toBe(2);
- expect(vm.$el.querySelectorAll('.tab-pane')[0].textContent).toContain('First tab');
- expect(vm.$el.querySelectorAll('.tab-pane')[1].textContent).toContain('Second tab');
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/toggle_button_spec.js b/spec/javascripts/vue_shared/components/toggle_button_spec.js
deleted file mode 100644
index ea0a89a3ab5..00000000000
--- a/spec/javascripts/vue_shared/components/toggle_button_spec.js
+++ /dev/null
@@ -1,101 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import toggleButton from '~/vue_shared/components/toggle_button.vue';
-
-describe('Toggle Button', () => {
- let vm;
- let Component;
-
- beforeEach(() => {
- Component = Vue.extend(toggleButton);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('render output', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- value: true,
- name: 'foo',
- });
- });
-
- it('renders input with provided name', () => {
- expect(vm.$el.querySelector('input').getAttribute('name')).toEqual('foo');
- });
-
- it('renders input with provided value', () => {
- expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true');
- });
-
- it('renders input status icon', () => {
- expect(vm.$el.querySelectorAll('span.toggle-icon').length).toEqual(1);
- expect(vm.$el.querySelectorAll('svg.s16.toggle-icon-svg').length).toEqual(1);
- });
- });
-
- describe('is-checked', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- value: true,
- });
-
- spyOn(vm, '$emit');
- });
-
- it('renders is checked class', () => {
- expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true);
- });
-
- it('sets aria-label representing toggle state', () => {
- vm.value = true;
-
- expect(vm.ariaLabel).toEqual('Toggle Status: ON');
-
- vm.value = false;
-
- expect(vm.ariaLabel).toEqual('Toggle Status: OFF');
- });
-
- it('emits change event when clicked', () => {
- vm.$el.querySelector('button').click();
-
- expect(vm.$emit).toHaveBeenCalledWith('change', false);
- });
- });
-
- describe('is-disabled', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- value: true,
- disabledInput: true,
- });
- spyOn(vm, '$emit');
- });
-
- it('renders disabled button', () => {
- expect(vm.$el.querySelector('button').classList.contains('is-disabled')).toEqual(true);
- });
-
- it('does not emit change event when clicked', () => {
- vm.$el.querySelector('button').click();
-
- expect(vm.$emit).not.toHaveBeenCalled();
- });
- });
-
- describe('is-loading', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- value: true,
- isLoading: true,
- });
- });
-
- it('renders loading class', () => {
- expect(vm.$el.querySelector('button').classList.contains('is-loading')).toEqual(true);
- });
- });
-});
diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_svg_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_svg_spec.js
deleted file mode 100644
index 31644416439..00000000000
--- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_svg_spec.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import Vue from 'vue';
-import avatarSvg from 'icons/_icon_random.svg';
-import UserAvatarSvg from '~/vue_shared/components/user_avatar/user_avatar_svg.vue';
-
-const UserAvatarSvgComponent = Vue.extend(UserAvatarSvg);
-
-describe('User Avatar Svg Component', function() {
- describe('Initialization', function() {
- beforeEach(function() {
- this.propsData = {
- size: 99,
- svg: avatarSvg,
- };
-
- this.userAvatarSvg = new UserAvatarSvgComponent({
- propsData: this.propsData,
- }).$mount();
- });
-
- it('should return a defined Vue component', function() {
- expect(this.userAvatarSvg).toBeDefined();
- });
-
- it('should have <svg> as a child element', function() {
- expect(this.userAvatarSvg.$el.tagName).toEqual('svg');
- expect(this.userAvatarSvg.$el.innerHTML).toContain('<path');
- });
- });
-});
diff --git a/spec/lib/api/entities/branch_spec.rb b/spec/lib/api/entities/branch_spec.rb
new file mode 100644
index 00000000000..604f56c0cb2
--- /dev/null
+++ b/spec/lib/api/entities/branch_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe API::Entities::Branch do
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:repository) { project.repository }
+ let(:branch) { repository.find_branch('master') }
+ let(:entity) { described_class.new(branch, project: project) }
+
+ it 'includes basic fields', :aggregate_failures do
+ is_expected.to include(
+ name: 'master',
+ commit: a_kind_of(Hash),
+ merged: false,
+ protected: false,
+ developers_can_push: false,
+ developers_can_merge: false,
+ can_push: false,
+ default: true,
+ web_url: Gitlab::Routing.url_helpers.project_tree_url(project, 'master')
+ )
+ end
+ end
+end
diff --git a/spec/lib/api/entities/design_management/design_spec.rb b/spec/lib/api/entities/design_management/design_spec.rb
new file mode 100644
index 00000000000..50ca3b43c6a
--- /dev/null
+++ b/spec/lib/api/entities/design_management/design_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe API::Entities::DesignManagement::Design do
+ let_it_be(:design) { create(:design) }
+ let(:entity) { described_class.new(design, request: double) }
+
+ subject { entity.as_json }
+
+ it 'has the correct attributes' do
+ expect(subject).to eq({
+ id: design.id,
+ project_id: design.project_id,
+ filename: design.filename,
+ image_url: ::Gitlab::UrlBuilder.build(design)
+ })
+ end
+end
diff --git a/spec/lib/api/entities/project_repository_storage_move_spec.rb b/spec/lib/api/entities/project_repository_storage_move_spec.rb
new file mode 100644
index 00000000000..1c38c8231d4
--- /dev/null
+++ b/spec/lib/api/entities/project_repository_storage_move_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe API::Entities::ProjectRepositoryStorageMove do
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ let(:storage_move) { build(:project_repository_storage_move, :scheduled, destination_storage_name: 'test_second_storage') }
+ let(:entity) { described_class.new(storage_move) }
+
+ it 'includes basic fields' do
+ is_expected.to include(
+ state: 'scheduled',
+ source_storage_name: 'default',
+ destination_storage_name: 'test_second_storage',
+ project: a_kind_of(Hash)
+ )
+ end
+ end
+end
diff --git a/spec/lib/api/entities/snippet_spec.rb b/spec/lib/api/entities/snippet_spec.rb
new file mode 100644
index 00000000000..dada0942e49
--- /dev/null
+++ b/spec/lib/api/entities/snippet_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ::API::Entities::Snippet do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:personal_snippet) { create(:personal_snippet, :repository, author: user ) }
+ let_it_be(:project_snippet) { create(:project_snippet, :repository, author: user) }
+
+ let(:entity) { described_class.new(snippet) }
+
+ subject { entity.as_json }
+
+ shared_examples 'common attributes' do
+ it { expect(subject[:id]).to eq snippet.id }
+ it { expect(subject[:title]).to eq snippet.title }
+ it { expect(subject[:description]).to eq snippet.description }
+ it { expect(subject[:updated_at]).to eq snippet.updated_at }
+ it { expect(subject[:created_at]).to eq snippet.created_at }
+ it { expect(subject[:project_id]).to eq snippet.project_id }
+ it { expect(subject[:visibility]).to eq snippet.visibility }
+ it { expect(subject).to include(:author) }
+
+ describe 'file_name' do
+ it 'returns attribute from repository' do
+ expect(subject[:file_name]).to eq snippet.blobs.first.path
+ end
+
+ context 'when repository is empty' do
+ it 'returns attribute from db' do
+ allow(snippet.repository).to receive(:empty?).and_return(true)
+
+ expect(subject[:file_name]).to eq snippet.file_name
+ end
+ end
+ end
+
+ describe 'ssh_url_to_repo' do
+ it 'returns attribute' do
+ expect(subject[:ssh_url_to_repo]).to eq snippet.ssh_url_to_repo
+ end
+
+ context 'when repository does not exist' do
+ it 'does not include attribute' do
+ allow(snippet).to receive(:repository_exists?).and_return(false)
+
+ expect(subject).not_to include(:ssh_url_to_repo)
+ end
+ end
+ end
+
+ describe 'http_url_to_repo' do
+ it 'returns attribute' do
+ expect(subject[:http_url_to_repo]).to eq snippet.http_url_to_repo
+ end
+
+ context 'when repository does not exist' do
+ it 'does not include attribute' do
+ allow(snippet).to receive(:repository_exists?).and_return(false)
+
+ expect(subject).not_to include(:http_url_to_repo)
+ end
+ end
+ end
+ end
+
+ context 'with PersonalSnippet' do
+ let(:snippet) { personal_snippet }
+
+ it_behaves_like 'common attributes'
+
+ it 'returns snippet web_url attribute' do
+ expect(subject[:web_url]).to match("/snippets/#{snippet.id}")
+ end
+
+ it 'returns snippet raw_url attribute' do
+ expect(subject[:raw_url]).to match("/snippets/#{snippet.id}/raw")
+ end
+ end
+
+ context 'with ProjectSnippet' do
+ let(:snippet) { project_snippet }
+
+ it_behaves_like 'common attributes'
+
+ it 'returns snippet web_url attribute' do
+ expect(subject[:web_url]).to match("#{snippet.project.full_path}/snippets/#{snippet.id}")
+ end
+
+ it 'returns snippet raw_url attribute' do
+ expect(subject[:raw_url]).to match("#{snippet.project.full_path}/snippets/#{snippet.id}/raw")
+ end
+ end
+end
diff --git a/spec/lib/api/helpers/pagination_strategies_spec.rb b/spec/lib/api/helpers/pagination_strategies_spec.rb
index a418c09a824..eaa71159714 100644
--- a/spec/lib/api/helpers/pagination_strategies_spec.rb
+++ b/spec/lib/api/helpers/pagination_strategies_spec.rb
@@ -6,7 +6,7 @@ describe API::Helpers::PaginationStrategies do
subject { Class.new.include(described_class).new }
let(:expected_result) { double("result") }
- let(:relation) { double("relation") }
+ let(:relation) { double("relation", klass: "SomeClass") }
let(:params) { {} }
before do
@@ -17,18 +17,18 @@ describe API::Helpers::PaginationStrategies do
let(:paginator) { double("paginator", paginate: expected_result, finalize: nil) }
before do
- allow(subject).to receive(:paginator).with(relation).and_return(paginator)
+ allow(subject).to receive(:paginator).with(relation, nil).and_return(paginator)
end
it 'yields paginated relation' do
- expect { |b| subject.paginate_with_strategies(relation, &b) }.to yield_with_args(expected_result)
+ expect { |b| subject.paginate_with_strategies(relation, nil, &b) }.to yield_with_args(expected_result)
end
it 'calls #finalize with first value returned from block' do
return_value = double
expect(paginator).to receive(:finalize).with(return_value)
- subject.paginate_with_strategies(relation) do |records|
+ subject.paginate_with_strategies(relation, nil) do |records|
some_options = {}
[return_value, some_options]
end
@@ -37,7 +37,7 @@ describe API::Helpers::PaginationStrategies do
it 'returns whatever the block returns' do
return_value = [double, double]
- result = subject.paginate_with_strategies(relation) do |records|
+ result = subject.paginate_with_strategies(relation, nil) do |records|
return_value
end
@@ -47,16 +47,77 @@ describe API::Helpers::PaginationStrategies do
describe '#paginator' do
context 'offset pagination' do
+ let(:plan_limits) { Plan.default.actual_limits }
+ let(:offset_limit) { plan_limits.offset_pagination_limit }
let(:paginator) { double("paginator") }
before do
allow(subject).to receive(:keyset_pagination_enabled?).and_return(false)
end
- it 'delegates to OffsetPagination' do
- expect(Gitlab::Pagination::OffsetPagination).to receive(:new).with(subject).and_return(paginator)
+ context 'when keyset pagination is available for the relation' do
+ before do
+ allow(Gitlab::Pagination::Keyset).to receive(:available_for_type?).and_return(true)
+ end
+
+ context 'when a request scope is given' do
+ let(:params) { { per_page: 100, page: offset_limit / 100 + 1 } }
+ let(:request_scope) { double("scope", actual_limits: plan_limits) }
+
+ context 'when the scope limit is exceeded' do
+ it 'renders a 405 error' do
+ expect(subject).to receive(:error!).with(/maximum allowed offset/, 405)
+
+ subject.paginator(relation, request_scope)
+ end
+ end
+
+ context 'when the scope limit is not exceeded' do
+ let(:params) { { per_page: 100, page: offset_limit / 100 } }
+
+ it 'delegates to OffsetPagination' do
+ expect(Gitlab::Pagination::OffsetPagination).to receive(:new).with(subject).and_return(paginator)
+
+ expect(subject.paginator(relation, request_scope)).to eq(paginator)
+ end
+ end
+ end
+
+ context 'when a request scope is not given' do
+ context 'when the default limits are exceeded' do
+ let(:params) { { per_page: 100, page: offset_limit / 100 + 1 } }
+
+ it 'renders a 405 error' do
+ expect(subject).to receive(:error!).with(/maximum allowed offset/, 405)
+
+ subject.paginator(relation)
+ end
+ end
- expect(subject.paginator(relation)).to eq(paginator)
+ context 'when the default limits are not exceeded' do
+ let(:params) { { per_page: 100, page: offset_limit / 100 } }
+
+ it 'delegates to OffsetPagination' do
+ expect(Gitlab::Pagination::OffsetPagination).to receive(:new).with(subject).and_return(paginator)
+
+ expect(subject.paginator(relation)).to eq(paginator)
+ end
+ end
+ end
+ end
+
+ context 'when keyset pagination is not available for the relation' do
+ let(:params) { { per_page: 100, page: offset_limit / 100 + 1 } }
+
+ before do
+ allow(Gitlab::Pagination::Keyset).to receive(:available_for_type?).and_return(false)
+ end
+
+ it 'delegates to OffsetPagination' do
+ expect(Gitlab::Pagination::OffsetPagination).to receive(:new).with(subject).and_return(paginator)
+
+ expect(subject.paginator(relation)).to eq(paginator)
+ end
end
end
diff --git a/spec/lib/banzai/filter/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/issue_reference_filter_spec.rb
index 4a412da27a7..61c59162a30 100644
--- a/spec/lib/banzai/filter/issue_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/issue_reference_filter_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
describe Banzai::Filter::IssueReferenceFilter do
include FilterSpecHelper
+ include DesignManagementTestHelpers
def helper
IssuesHelper
@@ -358,6 +359,23 @@ describe Banzai::Filter::IssueReferenceFilter do
end
end
+ context 'when processing a link to the designs tab' do
+ let(:designs_tab_url) { url_for_designs(issue) }
+ let(:input_text) { "See #{designs_tab_url}" }
+
+ subject(:link) { reference_filter(input_text).css('a').first }
+
+ before do
+ enable_design_management
+ end
+
+ it 'includes the word "designs" after the reference in the text content', :aggregate_failures do
+ expect(link.attr('title')).to eq(issue.title)
+ expect(link.attr('href')).to eq(designs_tab_url)
+ expect(link.text).to eq("#{issue.to_reference} (designs)")
+ end
+ end
+
context 'group context' do
let(:group) { create(:group) }
let(:context) { { project: nil, group: group } }
@@ -467,4 +485,41 @@ describe Banzai::Filter::IssueReferenceFilter do
end.not_to yield_control
end
end
+
+ describe '#object_link_text_extras' do
+ before do
+ enable_design_management(enabled)
+ end
+
+ let(:current_user) { project.owner }
+ let(:enabled) { true }
+ let(:matches) { Issue.link_reference_pattern.match(input_text) }
+ let(:extras) { subject.object_link_text_extras(issue, matches) }
+
+ subject { filter_instance }
+
+ context 'the link does not go to the designs tab' do
+ let(:input_text) { Gitlab::Routing.url_helpers.project_issue_url(issue.project, issue) }
+
+ it 'does not include designs' do
+ expect(extras).not_to include('designs')
+ end
+ end
+
+ context 'the link goes to the designs tab' do
+ let(:input_text) { url_for_designs(issue) }
+
+ it 'includes designs' do
+ expect(extras).to include('designs')
+ end
+
+ context 'design management is disabled' do
+ let(:enabled) { false }
+
+ it 'does not include designs in the extras' do
+ expect(extras).not_to include('designs')
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/banzai/filter/upload_link_filter_spec.rb b/spec/lib/banzai/filter/upload_link_filter_spec.rb
index 3f181dce7bc..c366f774895 100644
--- a/spec/lib/banzai/filter/upload_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/upload_link_filter_spec.rb
@@ -51,6 +51,7 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('a')['href']).to eq(absolute_path)
expect(doc.at_css('a').classes).to include('gfm')
+ expect(doc.at_css('a')['data-link']).to eq('true')
end
end
@@ -59,11 +60,13 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('a')['href']).to eq(relative_path)
expect(doc.at_css('a').classes).to include('gfm')
+ expect(doc.at_css('a')['data-link']).to eq('true')
doc = filter(nested(link(upload_path)))
expect(doc.at_css('a')['href']).to eq(relative_path)
expect(doc.at_css('a').classes).to include('gfm')
+ expect(doc.at_css('a')['data-link']).to eq('true')
end
it 'rebuilds relative URL for an image' do
@@ -71,11 +74,13 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('img')['src']).to eq(relative_path)
expect(doc.at_css('img').classes).to include('gfm')
+ expect(doc.at_css('img')['data-link']).not_to eq('true')
doc = filter(nested(image(upload_path)))
expect(doc.at_css('img')['src']).to eq(relative_path)
expect(doc.at_css('img').classes).to include('gfm')
+ expect(doc.at_css('img')['data-link']).not_to eq('true')
end
it 'does not modify absolute URL' do
@@ -83,6 +88,7 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('a')['href']).to eq 'http://example.com'
expect(doc.at_css('a').classes).not_to include('gfm')
+ expect(doc.at_css('a')['data-link']).not_to eq('true')
end
it 'supports unescaped Unicode filenames' do
@@ -91,6 +97,7 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('a')['href']).to eq("/#{project.full_path}/uploads/%ED%95%9C%EA%B8%80.png")
expect(doc.at_css('a').classes).to include('gfm')
+ expect(doc.at_css('a')['data-link']).to eq('true')
end
it 'supports escaped Unicode filenames' do
@@ -100,6 +107,7 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('img')['src']).to eq("/#{project.full_path}/uploads/%ED%95%9C%EA%B8%80.png")
expect(doc.at_css('img').classes).to include('gfm')
+ expect(doc.at_css('img')['data-link']).not_to eq('true')
end
end
@@ -118,6 +126,7 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('a')['href']).to eq(absolute_path)
expect(doc.at_css('a').classes).to include('gfm')
+ expect(doc.at_css('a')['data-link']).to eq('true')
end
end
@@ -126,6 +135,7 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('a')['href']).to eq(relative_path)
expect(doc.at_css('a').classes).to include('gfm')
+ expect(doc.at_css('a')['data-link']).to eq('true')
end
it 'rewrites the link correctly for subgroup' do
@@ -135,6 +145,7 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('a')['href']).to eq(relative_path)
expect(doc.at_css('a').classes).to include('gfm')
+ expect(doc.at_css('a')['data-link']).to eq('true')
end
it 'does not modify absolute URL' do
@@ -142,6 +153,7 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('a')['href']).to eq 'http://example.com'
expect(doc.at_css('a').classes).not_to include('gfm')
+ expect(doc.at_css('a')['data-link']).not_to eq('true')
end
end
@@ -159,6 +171,7 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('a')['href']).to eq(absolute_path)
expect(doc.at_css('a').classes).to include('gfm')
+ expect(doc.at_css('a')['data-link']).to eq('true')
end
end
@@ -178,6 +191,7 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('a')['href']).to eq(absolute_path)
expect(doc.at_css('a').classes).to include('gfm')
+ expect(doc.at_css('a')['data-link']).to eq('true')
end
end
@@ -186,6 +200,7 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('a')['href']).to eq(gitlab_root + relative_path)
expect(doc.at_css('a').classes).to include('gfm')
+ expect(doc.at_css('a')['data-link']).to eq('true')
end
end
@@ -194,6 +209,7 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('a')['href']).to eq(relative_path)
expect(doc.at_css('a').classes).to include('gfm')
+ expect(doc.at_css('a')['data-link']).to eq('true')
end
it 'does not modify absolute URL' do
@@ -201,6 +217,7 @@ describe Banzai::Filter::UploadLinkFilter do
expect(doc.at_css('a')['href']).to eq 'http://example.com'
expect(doc.at_css('a').classes).not_to include('gfm')
+ expect(doc.at_css('a')['data-link']).not_to eq('true')
end
end
diff --git a/spec/lib/banzai/filter/user_reference_filter_spec.rb b/spec/lib/banzai/filter/user_reference_filter_spec.rb
index a09aeb7d7f6..cd6b68343b5 100644
--- a/spec/lib/banzai/filter/user_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/user_reference_filter_spec.rb
@@ -123,7 +123,7 @@ describe Banzai::Filter::UserReferenceFilter do
it 'includes default classes' do
doc = reference_filter("Hey #{reference}")
- expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member'
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member js-user-link'
end
context 'when a project is not specified' do
diff --git a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb
index 8c009bc409b..4d16c568c13 100644
--- a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb
+++ b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb
@@ -57,10 +57,10 @@ describe Banzai::Pipeline::WikiPipeline do
let(:namespace) { create(:namespace, name: "wiki_link_ns") }
let(:project) { create(:project, :public, name: "wiki_link_project", namespace: namespace) }
let(:project_wiki) { ProjectWiki.new(project, double(:user)) }
- let(:page) { build(:wiki_page, wiki: project_wiki, page: OpenStruct.new(url_path: 'nested/twice/start-page')) }
+ let(:page) { build(:wiki_page, wiki: project_wiki, title: 'nested/twice/start-page') }
- { "when GitLab is hosted at a root URL" => '/',
- "when GitLab is hosted at a relative URL" => '/nested/relative/gitlab' }.each do |test_name, relative_url_root|
+ { 'when GitLab is hosted at a root URL' => '',
+ 'when GitLab is hosted at a relative URL' => '/nested/relative/gitlab' }.each do |test_name, relative_url_root|
context test_name do
before do
allow(Gitlab.config.gitlab).to receive(:relative_url_root).and_return(relative_url_root)
@@ -264,7 +264,7 @@ describe Banzai::Pipeline::WikiPipeline do
let_it_be(:namespace) { create(:namespace, name: "wiki_link_ns") }
let_it_be(:project) { create(:project, :public, name: "wiki_link_project", namespace: namespace) }
let_it_be(:project_wiki) { ProjectWiki.new(project, double(:user)) }
- let_it_be(:page) { build(:wiki_page, wiki: project_wiki, page: OpenStruct.new(url_path: 'nested/twice/start-page')) }
+ let_it_be(:page) { build(:wiki_page, wiki: project_wiki, title: 'nested/twice/start-page') }
it 'generates video html structure' do
markdown = "![video_file](video_file_name.mp4)"
diff --git a/spec/lib/banzai/reference_parser/design_parser_spec.rb b/spec/lib/banzai/reference_parser/design_parser_spec.rb
new file mode 100644
index 00000000000..76708acf887
--- /dev/null
+++ b/spec/lib/banzai/reference_parser/design_parser_spec.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Banzai::ReferenceParser::DesignParser do
+ include ReferenceParserHelpers
+ include DesignManagementTestHelpers
+
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:design) { create(:design, :with_versions, issue: issue) }
+ let_it_be(:user) { create(:user, developer_projects: [issue.project]) }
+
+ subject(:instance) { described_class.new(Banzai::RenderContext.new(issue.project, user)) }
+
+ let(:link) { design_link(design) }
+
+ before do
+ enable_design_management
+ end
+
+ describe '#nodes_visible_to_user' do
+ it_behaves_like 'referenced feature visibility', 'issues' do
+ let(:project) { issue.project }
+ end
+
+ describe 'specific states' do
+ let_it_be(:public_project) { create(:project, :public) }
+
+ let_it_be(:other_project_link) do
+ design_link(create(:design, :with_versions))
+ end
+ let_it_be(:public_link) do
+ design_link(create(:design, :with_versions, issue: create(:issue, project: public_project)))
+ end
+ let_it_be(:public_but_confidential_link) do
+ design_link(create(:design, :with_versions, issue: create(:issue, :confidential, project: public_project)))
+ end
+
+ subject(:visible_nodes) do
+ nodes = [link,
+ other_project_link,
+ public_link,
+ public_but_confidential_link]
+
+ instance.nodes_visible_to_user(user, nodes)
+ end
+
+ it 'redacts links we should not have access to' do
+ expect(visible_nodes).to contain_exactly(link, public_link)
+ end
+
+ context 'design management is not available' do
+ before do
+ enable_design_management(false)
+ end
+
+ it 'redacts all nodes' do
+ expect(visible_nodes).to be_empty
+ end
+ end
+ end
+ end
+
+ describe '#process' do
+ it 'returns the correct designs' do
+ frag = document([design, create(:design, :with_versions)])
+
+ expect(subject.process([frag])[:visible]).to contain_exactly(design)
+ end
+ end
+
+ def design_link(design)
+ node = empty_html_link
+ node['class'] = 'gfm'
+ node['data-reference-type'] = 'design'
+ node['data-project'] = design.project.id.to_s
+ node['data-issue'] = design.issue.id.to_s
+ node['data-design'] = design.id.to_s
+
+ node
+ end
+
+ def document(designs)
+ frag = Nokogiri::HTML.fragment('')
+ designs.each do |design|
+ frag.add_child(design_link(design))
+ end
+
+ frag
+ end
+end
diff --git a/spec/lib/banzai/renderer_spec.rb b/spec/lib/banzai/renderer_spec.rb
index 0d329b47aa3..b540a76face 100644
--- a/spec/lib/banzai/renderer_spec.rb
+++ b/spec/lib/banzai/renderer_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
describe Banzai::Renderer do
+ let(:renderer) { described_class }
+
def fake_object(fresh:)
object = double('object')
@@ -40,8 +42,6 @@ describe Banzai::Renderer do
end
describe '#render_field' do
- let(:renderer) { described_class }
-
context 'without cache' do
let(:commit) { fake_cacheless_object }
@@ -83,4 +83,57 @@ describe Banzai::Renderer do
end
end
end
+
+ describe '#post_process' do
+ let(:context_options) { {} }
+ let(:html) { 'Consequatur aperiam et nesciunt modi aut assumenda quo id. '}
+ let(:post_processed_html) { double(html_safe: 'safe doc') }
+ let(:doc) { double(to_html: post_processed_html) }
+
+ subject { renderer.post_process(html, context_options) }
+
+ context 'when xhtml' do
+ let(:context_options) { { xhtml: ' ' } }
+
+ context 'without :post_process_pipeline key' do
+ it 'uses PostProcessPipeline' do
+ expect(::Banzai::Pipeline::PostProcessPipeline).to receive(:to_document).and_return(doc)
+
+ subject
+ end
+ end
+
+ context 'with :post_process_pipeline key' do
+ let(:context_options) { { post_process_pipeline: Object, xhtml: ' ' } }
+
+ it 'uses passed post process pipeline' do
+ expect(Object).to receive(:to_document).and_return(doc)
+
+ subject
+ end
+ end
+ end
+
+ context 'when not xhtml' do
+ context 'without :post_process_pipeline key' do
+ it 'uses PostProcessPipeline' do
+ expect(::Banzai::Pipeline::PostProcessPipeline).to receive(:to_html)
+ .with(html, { only_path: true, disable_asset_proxy: true })
+ .and_return(post_processed_html)
+
+ subject
+ end
+ end
+
+ context 'with :post_process_pipeline key' do
+ let(:context_options) { { post_process_pipeline: Object } }
+
+ it 'uses passed post process pipeline' do
+ expect(Object).to receive(:to_html).and_return(post_processed_html)
+
+ subject
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/bitbucket_server/representation/activity_spec.rb b/spec/lib/bitbucket_server/representation/activity_spec.rb
index b548dedadfb..6988e77ad25 100644
--- a/spec/lib/bitbucket_server/representation/activity_spec.rb
+++ b/spec/lib/bitbucket_server/representation/activity_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe BitbucketServer::Representation::Activity do
- let(:activities) { JSON.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] }
+ let(:activities) { Gitlab::Json.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] }
let(:inline_comment) { activities.first }
let(:comment) { activities[3] }
let(:merge_event) { activities[4] }
diff --git a/spec/lib/bitbucket_server/representation/comment_spec.rb b/spec/lib/bitbucket_server/representation/comment_spec.rb
index f8c73c3da35..ecaf6a843ae 100644
--- a/spec/lib/bitbucket_server/representation/comment_spec.rb
+++ b/spec/lib/bitbucket_server/representation/comment_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe BitbucketServer::Representation::Comment do
- let(:activities) { JSON.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] }
+ let(:activities) { Gitlab::Json.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] }
let(:comment) { activities.first }
subject { described_class.new(comment) }
diff --git a/spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb b/spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb
index db43e990812..aa3eddf305a 100644
--- a/spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb
+++ b/spec/lib/bitbucket_server/representation/pull_request_comment_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe BitbucketServer::Representation::PullRequestComment do
- let(:activities) { JSON.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] }
+ let(:activities) { Gitlab::Json.parse(fixture_file('importers/bitbucket_server/activities.json'))['values'] }
let(:comment) { activities.second }
subject { described_class.new(comment) }
diff --git a/spec/lib/bitbucket_server/representation/pull_request_spec.rb b/spec/lib/bitbucket_server/representation/pull_request_spec.rb
index e091890041e..7e72da05cb1 100644
--- a/spec/lib/bitbucket_server/representation/pull_request_spec.rb
+++ b/spec/lib/bitbucket_server/representation/pull_request_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe BitbucketServer::Representation::PullRequest do
- let(:sample_data) { JSON.parse(fixture_file('importers/bitbucket_server/pull_request.json')) }
+ let(:sample_data) { Gitlab::Json.parse(fixture_file('importers/bitbucket_server/pull_request.json')) }
subject { described_class.new(sample_data) }
diff --git a/spec/lib/bitbucket_server/representation/repo_spec.rb b/spec/lib/bitbucket_server/representation/repo_spec.rb
index 801de247d73..429b6d36c59 100644
--- a/spec/lib/bitbucket_server/representation/repo_spec.rb
+++ b/spec/lib/bitbucket_server/representation/repo_spec.rb
@@ -50,7 +50,7 @@ describe BitbucketServer::Representation::Repo do
DATA
end
- subject { described_class.new(JSON.parse(sample_data)) }
+ subject { described_class.new(Gitlab::Json.parse(sample_data)) }
describe '#project_key' do
it { expect(subject.project_key).to eq('TEST') }
diff --git a/spec/lib/container_registry/client_spec.rb b/spec/lib/container_registry/client_spec.rb
index 0aad6568793..18bcff65f41 100644
--- a/spec/lib/container_registry/client_spec.rb
+++ b/spec/lib/container_registry/client_spec.rb
@@ -85,7 +85,7 @@ describe ContainerRegistry::Client do
it 'follows 307 redirect for GET /v2/:name/blobs/:digest' do
stub_request(:get, "http://container-registry/v2/group/test/blobs/sha256:0123456789012345")
.with(headers: blob_headers)
- .to_return(status: 307, body: "", headers: { Location: 'http://redirected' })
+ .to_return(status: 307, body: '', headers: { Location: 'http://redirected' })
# We should probably use hash_excluding here, but that requires an update to WebMock:
# https://github.com/bblimke/webmock/blob/master/lib/webmock/matchers/hash_excluding_matcher.rb
stub_request(:get, "http://redirected/")
@@ -238,4 +238,54 @@ describe ContainerRegistry::Client do
it { is_expected.to be_falsey }
end
end
+
+ def stub_registry_info(headers: {}, status: 200)
+ stub_request(:get, 'http://container-registry/v2/')
+ .to_return(status: status, body: "", headers: headers)
+ end
+
+ describe '#registry_info' do
+ subject { client.registry_info }
+
+ context 'when the check is successful' do
+ context 'when using the GitLab container registry' do
+ before do
+ stub_registry_info(headers: {
+ 'GitLab-Container-Registry-Version' => '2.9.1-gitlab',
+ 'GitLab-Container-Registry-Features' => 'a,b,c'
+ })
+ end
+
+ it 'identifies the vendor as "gitlab"' do
+ expect(subject).to include(vendor: 'gitlab')
+ end
+
+ it 'identifies version and features' do
+ expect(subject).to include(version: '2.9.1-gitlab', features: %w[a b c])
+ end
+ end
+
+ context 'when using a third-party container registry' do
+ before do
+ stub_registry_info
+ end
+
+ it 'identifies the vendor as "other"' do
+ expect(subject).to include(vendor: 'other')
+ end
+
+ it 'does not identify version or features' do
+ expect(subject).to include(version: nil, features: [])
+ end
+ end
+ end
+
+ context 'when the check is not successful' do
+ it 'does not identify vendor, version or features' do
+ stub_registry_info(status: 500)
+
+ expect(subject).to eq({})
+ end
+ end
+ end
end
diff --git a/spec/lib/declarative_policy_spec.rb b/spec/lib/declarative_policy_spec.rb
new file mode 100644
index 00000000000..5fdb3c27738
--- /dev/null
+++ b/spec/lib/declarative_policy_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DeclarativePolicy do
+ describe '.class_for' do
+ it 'uses declarative_policy_class if present' do
+ instance = Gitlab::ErrorTracking::ErrorEvent.new
+
+ expect(described_class.class_for(instance)).to eq(ErrorTracking::BasePolicy)
+ end
+
+ it 'infers policy class from name' do
+ instance = PersonalSnippet.new
+
+ expect(described_class.class_for(instance)).to eq(PersonalSnippetPolicy)
+ end
+
+ it 'raises error if not found' do
+ instance = Object.new
+
+ expect { described_class.class_for(instance) }.to raise_error('no policy for Object')
+ end
+
+ context 'when found policy class does not inherit base' do
+ before do
+ stub_const('Foo', Class.new)
+ stub_const('FooPolicy', Class.new)
+ end
+
+ it 'raises error if inferred class does not inherit Base' do
+ instance = Foo.new
+
+ expect { described_class.class_for(instance) }.to raise_error('no policy for Foo')
+ end
+ end
+ end
+end
diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb
index 2890b8d4f3b..81fa2dc5cad 100644
--- a/spec/lib/feature_spec.rb
+++ b/spec/lib/feature_spec.rb
@@ -146,14 +146,6 @@ describe Feature do
expect(described_class.enabled?(:enabled_feature_flag)).to be_truthy
end
- context 'with USE_THREAD_MEMORY_CACHE defined' do
- before do
- stub_env('USE_THREAD_MEMORY_CACHE', '1')
- end
-
- it { expect(described_class.l1_cache_backend).to eq(Gitlab::ThreadMemoryCache.cache_backend) }
- end
-
it { expect(described_class.l1_cache_backend).to eq(Gitlab::ProcessMemoryCache.cache_backend) }
it { expect(described_class.l2_cache_backend).to eq(Rails.cache) }
diff --git a/spec/lib/gitlab/alert_management/alert_params_spec.rb b/spec/lib/gitlab/alert_management/alert_params_spec.rb
new file mode 100644
index 00000000000..5cf34038f68
--- /dev/null
+++ b/spec/lib/gitlab/alert_management/alert_params_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::AlertManagement::AlertParams do
+ let_it_be(:project) { create(:project, :repository, :private) }
+
+ describe '.from_generic_alert' do
+ let(:started_at) { Time.current.change(usec: 0).rfc3339 }
+ let(:default_payload) do
+ {
+ 'title' => 'Alert title',
+ 'description' => 'Description',
+ 'monitoring_tool' => 'Monitoring tool name',
+ 'service' => 'Service',
+ 'hosts' => ['gitlab.com'],
+ 'start_time' => started_at,
+ 'some' => { 'extra' => { 'payload' => 'here' } }
+ }
+ end
+ let(:payload) { default_payload }
+
+ subject { described_class.from_generic_alert(project: project, payload: payload) }
+
+ it 'returns Alert compatible parameters' do
+ is_expected.to eq(
+ project_id: project.id,
+ title: 'Alert title',
+ description: 'Description',
+ monitoring_tool: 'Monitoring tool name',
+ service: 'Service',
+ severity: 'critical',
+ hosts: ['gitlab.com'],
+ payload: payload,
+ started_at: started_at
+ )
+ end
+
+ context 'when severity given' do
+ let(:payload) { default_payload.merge(severity: 'low') }
+
+ it 'returns Alert compatible parameters' do
+ expect(subject[:severity]).to eq('low')
+ end
+ end
+
+ context 'when there are no hosts in the payload' do
+ let(:payload) { {} }
+
+ it 'hosts param is an empty array' do
+ expect(subject[:hosts]).to be_empty
+ end
+ end
+ end
+
+ describe '.from_prometheus_alert' do
+ let(:payload) do
+ {
+ 'status' => 'firing',
+ 'labels' => {
+ 'alertname' => 'GitalyFileServerDown',
+ 'channel' => 'gitaly',
+ 'pager' => 'pagerduty',
+ 'severity' => 's1'
+ },
+ 'annotations' => {
+ 'description' => 'Alert description',
+ 'runbook' => 'troubleshooting/gitaly-down.md',
+ 'title' => 'Alert title'
+ },
+ 'startsAt' => '2020-04-27T10:10:22.265949279Z',
+ 'endsAt' => '0001-01-01T00:00:00Z',
+ 'generatorURL' => 'http://8d467bd4607a:9090/graph?g0.expr=vector%281%29&g0.tab=1',
+ 'fingerprint' => 'b6ac4d42057c43c1'
+ }
+ end
+ let(:parsed_alert) { Gitlab::Alerting::Alert.new(project: project, payload: payload) }
+
+ subject { described_class.from_prometheus_alert(project: project, parsed_alert: parsed_alert) }
+
+ it 'returns Alert-compatible params' do
+ is_expected.to eq(
+ project_id: project.id,
+ title: 'Alert title',
+ description: 'Alert description',
+ monitoring_tool: 'Prometheus',
+ payload: payload,
+ started_at: parsed_alert.starts_at,
+ ended_at: parsed_alert.ends_at,
+ fingerprint: parsed_alert.gitlab_fingerprint
+ )
+ end
+ end
+end
diff --git a/spec/lib/gitlab/alert_management/alert_status_counts_spec.rb b/spec/lib/gitlab/alert_management/alert_status_counts_spec.rb
new file mode 100644
index 00000000000..816ed918fe8
--- /dev/null
+++ b/spec/lib/gitlab/alert_management/alert_status_counts_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::AlertManagement::AlertStatusCounts do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:alert_1) { create(:alert_management_alert, :resolved, project: project) }
+ let_it_be(:alert_2) { create(:alert_management_alert, :ignored, project: project) }
+ let_it_be(:alert_3) { create(:alert_management_alert) }
+ let(:params) { {} }
+
+ describe '#execute' do
+ subject(:counts) { described_class.new(current_user, project, params) }
+
+ context 'for an unauthorized user' do
+ it 'returns zero for all statuses' do
+ expect(counts.open).to eq(0)
+ expect(counts.all).to eq(0)
+
+ AlertManagement::Alert::STATUSES.each_key do |status|
+ expect(counts.send(status)).to eq(0)
+ end
+ end
+ end
+
+ context 'for an authorized user' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ it 'returns the correct counts for each status' do
+ expect(counts.open).to eq(0)
+ expect(counts.all).to eq(2)
+ expect(counts.resolved).to eq(1)
+ expect(counts.ignored).to eq(1)
+ expect(counts.triggered).to eq(0)
+ expect(counts.acknowledged).to eq(0)
+ end
+
+ context 'when filtering params are included' do
+ let(:params) { { status: AlertManagement::Alert::STATUSES[:resolved] } }
+
+ it 'returns the correct counts for each status' do
+ expect(counts.open).to eq(0)
+ expect(counts.all).to eq(1)
+ expect(counts.resolved).to eq(1)
+ expect(counts.ignored).to eq(0)
+ expect(counts.triggered).to eq(0)
+ expect(counts.acknowledged).to eq(0)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/alerting/alert_spec.rb b/spec/lib/gitlab/alerting/alert_spec.rb
index 6d97f08af91..a0582515f3d 100644
--- a/spec/lib/gitlab/alerting/alert_spec.rb
+++ b/spec/lib/gitlab/alerting/alert_spec.rb
@@ -246,6 +246,30 @@ describe Gitlab::Alerting::Alert do
it_behaves_like 'parse payload', 'annotations/gitlab_incident_markdown'
end
+ describe '#gitlab_fingerprint' do
+ subject { alert.gitlab_fingerprint }
+
+ context 'when the alert is a GitLab managed alert' do
+ include_context 'gitlab alert'
+
+ it 'returns a fingerprint' do
+ plain_fingerprint = [alert.metric_id, alert.starts_at].join('/')
+
+ is_expected.to eq(Digest::SHA1.hexdigest(plain_fingerprint))
+ end
+ end
+
+ context 'when the alert is from self managed Prometheus' do
+ include_context 'full query'
+
+ it 'returns a fingerprint' do
+ plain_fingerprint = [alert.starts_at, alert.title, alert.full_query].join('/')
+
+ is_expected.to eq(Digest::SHA1.hexdigest(plain_fingerprint))
+ end
+ end
+ end
+
describe '#valid?' do
before do
payload.update(
diff --git a/spec/lib/gitlab/alerting/notification_payload_parser_spec.rb b/spec/lib/gitlab/alerting/notification_payload_parser_spec.rb
index a38aea7b972..f32095b3c86 100644
--- a/spec/lib/gitlab/alerting/notification_payload_parser_spec.rb
+++ b/spec/lib/gitlab/alerting/notification_payload_parser_spec.rb
@@ -12,7 +12,8 @@ describe Gitlab::Alerting::NotificationPayloadParser do
'description' => 'Description',
'monitoring_tool' => 'Monitoring tool name',
'service' => 'Service',
- 'hosts' => ['gitlab.com']
+ 'hosts' => ['gitlab.com'],
+ 'severity' => 'low'
}
end
@@ -26,7 +27,8 @@ describe Gitlab::Alerting::NotificationPayloadParser do
'description' => 'Description',
'monitoring_tool' => 'Monitoring tool name',
'service' => 'Service',
- 'hosts' => ['gitlab.com']
+ 'hosts' => ['gitlab.com'],
+ 'severity' => 'low'
},
'startsAt' => starts_at.rfc3339
}
@@ -67,11 +69,24 @@ describe Gitlab::Alerting::NotificationPayloadParser do
let(:payload) { {} }
it 'returns default parameters' do
- is_expected.to eq(
- 'annotations' => { 'title' => 'New: Incident' },
+ is_expected.to match(
+ 'annotations' => {
+ 'title' => described_class::DEFAULT_TITLE,
+ 'severity' => described_class::DEFAULT_SEVERITY
+ },
'startsAt' => starts_at.rfc3339
)
end
+
+ context 'when severity is blank' do
+ before do
+ payload[:severity] = ''
+ end
+
+ it 'sets severity to the default ' do
+ expect(subject.dig('annotations', 'severity')).to eq(described_class::DEFAULT_SEVERITY)
+ end
+ end
end
context 'when payload attributes have blank lines' do
@@ -88,7 +103,10 @@ describe Gitlab::Alerting::NotificationPayloadParser do
it 'returns default parameters' do
is_expected.to eq(
- 'annotations' => { 'title' => 'New: Incident' },
+ 'annotations' => {
+ 'title' => 'New: Incident',
+ 'severity' => described_class::DEFAULT_SEVERITY
+ },
'startsAt' => starts_at.rfc3339
)
end
@@ -112,6 +130,7 @@ describe Gitlab::Alerting::NotificationPayloadParser do
is_expected.to eq(
'annotations' => {
'title' => 'New: Incident',
+ 'severity' => described_class::DEFAULT_SEVERITY,
'description' => 'Description',
'additional.params.1' => 'Some value 1',
'additional.params.2' => 'Some value 2'
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/median_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/median_spec.rb
new file mode 100644
index 00000000000..92ecec350ae
--- /dev/null
+++ b/spec/lib/gitlab/analytics/cycle_analytics/median_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Analytics::CycleAnalytics::Median do
+ let_it_be(:project) { create(:project, :repository) }
+ let(:query) { Project.joins(merge_requests: :metrics) }
+
+ let(:stage) do
+ build(
+ :cycle_analytics_project_stage,
+ start_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestCreated.identifier,
+ end_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestMerged.identifier,
+ project: project
+ )
+ end
+
+ subject { described_class.new(stage: stage, query: query).seconds }
+
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ it 'retruns nil when no results' do
+ expect(subject).to eq(nil)
+ end
+
+ it 'returns median duration seconds as float' do
+ merge_request1 = create(:merge_request, source_branch: '1', target_project: project, source_project: project)
+ merge_request2 = create(:merge_request, source_branch: '2', target_project: project, source_project: project)
+
+ Timecop.travel(5.minutes.from_now) do
+ merge_request1.metrics.update!(merged_at: Time.zone.now)
+ end
+
+ Timecop.travel(10.minutes.from_now) do
+ merge_request2.metrics.update!(merged_at: Time.zone.now)
+ end
+
+ expect(subject).to be_within(0.5).of(7.5.minutes.seconds)
+ end
+end
diff --git a/spec/lib/gitlab/app_json_logger_spec.rb b/spec/lib/gitlab/app_json_logger_spec.rb
index 22a398f8bca..d11456236cc 100644
--- a/spec/lib/gitlab/app_json_logger_spec.rb
+++ b/spec/lib/gitlab/app_json_logger_spec.rb
@@ -9,10 +9,10 @@ describe Gitlab::AppJsonLogger do
let(:string_message) { 'Information' }
it 'logs a hash as a JSON' do
- expect(JSON.parse(subject.format_message('INFO', Time.now, nil, hash_message))).to include(hash_message)
+ expect(Gitlab::Json.parse(subject.format_message('INFO', Time.now, nil, hash_message))).to include(hash_message)
end
it 'logs a string as a JSON' do
- expect(JSON.parse(subject.format_message('INFO', Time.now, nil, string_message))).to include('message' => string_message)
+ expect(Gitlab::Json.parse(subject.format_message('INFO', Time.now, nil, string_message))).to include('message' => string_message)
end
end
diff --git a/spec/lib/gitlab/application_context_spec.rb b/spec/lib/gitlab/application_context_spec.rb
index 6674ea059a0..3be967ac8a4 100644
--- a/spec/lib/gitlab/application_context_spec.rb
+++ b/spec/lib/gitlab/application_context_spec.rb
@@ -55,10 +55,10 @@ describe Gitlab::ApplicationContext do
end
describe '#to_lazy_hash' do
- let(:user) { build(:user) }
- let(:project) { build(:project) }
- let(:namespace) { create(:group) }
- let(:subgroup) { create(:group, parent: namespace) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:namespace) { create(:group) }
+ let_it_be(:subgroup) { create(:group, parent: namespace) }
def result(context)
context.to_lazy_hash.transform_values { |v| v.respond_to?(:call) ? v.call : v }
@@ -106,5 +106,11 @@ describe Gitlab::ApplicationContext do
context.use {}
end
+
+ it 'does not cause queries' do
+ context = described_class.new(project: create(:project), namespace: create(:group, :nested), user: create(:user))
+
+ expect { context.use { Labkit::Context.current.to_h } }.not_to exceed_query_limit(0)
+ end
end
end
diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb
index 0b6fda31d7b..774a87752b9 100644
--- a/spec/lib/gitlab/auth/auth_finders_spec.rb
+++ b/spec/lib/gitlab/auth/auth_finders_spec.rb
@@ -17,6 +17,17 @@ describe Gitlab::Auth::AuthFinders do
request.update_param(key, value)
end
+ def set_header(key, value)
+ env[key] = value
+ end
+
+ def set_basic_auth_header(username, password)
+ set_header(
+ 'HTTP_AUTHORIZATION',
+ ActionController::HttpAuthentication::Basic.encode_credentials(username, password)
+ )
+ end
+
describe '#find_user_from_warden' do
context 'with CSRF token' do
before do
@@ -31,7 +42,7 @@ describe Gitlab::Auth::AuthFinders do
context 'with valid credentials' do
it 'returns the user' do
- env['warden'] = double("warden", authenticate: user)
+ set_header('warden', double("warden", authenticate: user))
expect(find_user_from_warden).to eq user
end
@@ -41,7 +52,7 @@ describe Gitlab::Auth::AuthFinders do
context 'without CSRF token' do
it 'returns nil' do
allow(Gitlab::RequestForgeryProtection).to receive(:verified?).and_return(false)
- env['warden'] = double("warden", authenticate: user)
+ set_header('warden', double("warden", authenticate: user))
expect(find_user_from_warden).to be_nil
end
@@ -51,8 +62,8 @@ describe Gitlab::Auth::AuthFinders do
describe '#find_user_from_feed_token' do
context 'when the request format is atom' do
before do
- env['SCRIPT_NAME'] = 'url.atom'
- env['HTTP_ACCEPT'] = 'application/atom+xml'
+ set_header('SCRIPT_NAME', 'url.atom')
+ set_header('HTTP_ACCEPT', 'application/atom+xml')
end
context 'when feed_token param is provided' do
@@ -94,7 +105,7 @@ describe Gitlab::Auth::AuthFinders do
context 'when the request format is not atom' do
it 'returns nil' do
- env['SCRIPT_NAME'] = 'json'
+ set_header('SCRIPT_NAME', 'json')
set_param(:feed_token, user.feed_token)
@@ -104,7 +115,7 @@ describe Gitlab::Auth::AuthFinders do
context 'when the request format is empty' do
it 'the method call does not modify the original value' do
- env['SCRIPT_NAME'] = 'url.atom'
+ set_header('SCRIPT_NAME', 'url.atom')
env.delete('action_dispatch.request.formats')
@@ -118,7 +129,7 @@ describe Gitlab::Auth::AuthFinders do
describe '#find_user_from_static_object_token' do
shared_examples 'static object request' do
before do
- env['SCRIPT_NAME'] = path
+ set_header('SCRIPT_NAME', path)
end
context 'when token header param is present' do
@@ -174,7 +185,7 @@ describe Gitlab::Auth::AuthFinders do
context 'when request format is not archive nor blob' do
before do
- env['script_name'] = 'url'
+ set_header('script_name', 'url')
end
it 'returns nil' do
@@ -183,11 +194,82 @@ describe Gitlab::Auth::AuthFinders do
end
end
+ describe '#deploy_token_from_request' do
+ let_it_be(:deploy_token) { create(:deploy_token) }
+ let_it_be(:route_authentication_setting) { { deploy_token_allowed: true } }
+
+ subject { deploy_token_from_request }
+
+ it { is_expected.to be_nil }
+
+ shared_examples 'an unauthenticated route' do
+ context 'when route is not allowed to use deploy_tokens' do
+ let(:route_authentication_setting) { { deploy_token_allowed: false } }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ context 'with deploy token headers' do
+ before do
+ set_header(described_class::DEPLOY_TOKEN_HEADER, deploy_token.token)
+ end
+
+ it { is_expected.to eq deploy_token }
+
+ it_behaves_like 'an unauthenticated route'
+
+ context 'with incorrect token' do
+ before do
+ set_header(described_class::DEPLOY_TOKEN_HEADER, 'invalid_token')
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ context 'with oauth headers' do
+ before do
+ set_header('HTTP_AUTHORIZATION', "Bearer #{deploy_token.token}")
+ end
+
+ it { is_expected.to eq deploy_token }
+
+ it_behaves_like 'an unauthenticated route'
+
+ context 'with invalid token' do
+ before do
+ set_header('HTTP_AUTHORIZATION', "Bearer invalid_token")
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ context 'with basic auth headers' do
+ before do
+ set_basic_auth_header(deploy_token.username, deploy_token.token)
+ end
+
+ it { is_expected.to eq deploy_token }
+
+ it_behaves_like 'an unauthenticated route'
+
+ context 'with incorrect token' do
+ before do
+ set_basic_auth_header(deploy_token.username, 'invalid')
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+ end
+
describe '#find_user_from_access_token' do
let(:personal_access_token) { create(:personal_access_token, user: user) }
before do
- env['SCRIPT_NAME'] = 'url.atom'
+ set_header('SCRIPT_NAME', 'url.atom')
end
it 'returns nil if no access_token present' do
@@ -196,13 +278,13 @@ describe Gitlab::Auth::AuthFinders do
context 'when validate_access_token! returns valid' do
it 'returns user' do
- env[described_class::PRIVATE_TOKEN_HEADER] = personal_access_token.token
+ set_header(described_class::PRIVATE_TOKEN_HEADER, personal_access_token.token)
expect(find_user_from_access_token).to eq user
end
it 'returns exception if token has no user' do
- env[described_class::PRIVATE_TOKEN_HEADER] = personal_access_token.token
+ set_header(described_class::PRIVATE_TOKEN_HEADER, personal_access_token.token)
allow_any_instance_of(PersonalAccessToken).to receive(:user).and_return(nil)
expect { find_user_from_access_token }.to raise_error(Gitlab::Auth::UnauthorizedError)
@@ -211,7 +293,7 @@ describe Gitlab::Auth::AuthFinders do
context 'with OAuth headers' do
it 'returns user' do
- env['HTTP_AUTHORIZATION'] = "Bearer #{personal_access_token.token}"
+ set_header('HTTP_AUTHORIZATION', "Bearer #{personal_access_token.token}")
expect(find_user_from_access_token).to eq user
end
@@ -228,7 +310,7 @@ describe Gitlab::Auth::AuthFinders do
let(:personal_access_token) { create(:personal_access_token, user: user) }
before do
- env[described_class::PRIVATE_TOKEN_HEADER] = personal_access_token.token
+ set_header(described_class::PRIVATE_TOKEN_HEADER, personal_access_token.token)
end
it 'returns exception if token has no user' do
@@ -252,19 +334,19 @@ describe Gitlab::Auth::AuthFinders do
end
it 'returns the user for RSS requests' do
- env['SCRIPT_NAME'] = 'url.atom'
+ set_header('SCRIPT_NAME', 'url.atom')
expect(find_user_from_web_access_token(:rss)).to eq(user)
end
it 'returns the user for ICS requests' do
- env['SCRIPT_NAME'] = 'url.ics'
+ set_header('SCRIPT_NAME', 'url.ics')
expect(find_user_from_web_access_token(:ics)).to eq(user)
end
it 'returns the user for API requests' do
- env['SCRIPT_NAME'] = '/api/endpoint'
+ set_header('SCRIPT_NAME', '/api/endpoint')
expect(find_user_from_web_access_token(:api)).to eq(user)
end
@@ -274,12 +356,12 @@ describe Gitlab::Auth::AuthFinders do
let(:personal_access_token) { create(:personal_access_token, user: user) }
before do
- env['SCRIPT_NAME'] = 'url.atom'
+ set_header('SCRIPT_NAME', 'url.atom')
end
context 'passed as header' do
it 'returns token if valid personal_access_token' do
- env[described_class::PRIVATE_TOKEN_HEADER] = personal_access_token.token
+ set_header(described_class::PRIVATE_TOKEN_HEADER, personal_access_token.token)
expect(find_personal_access_token).to eq personal_access_token
end
@@ -298,7 +380,7 @@ describe Gitlab::Auth::AuthFinders do
end
it 'returns exception if invalid personal_access_token' do
- env[described_class::PRIVATE_TOKEN_HEADER] = 'invalid_token'
+ set_header(described_class::PRIVATE_TOKEN_HEADER, 'invalid_token')
expect { find_personal_access_token }.to raise_error(Gitlab::Auth::UnauthorizedError)
end
@@ -310,7 +392,7 @@ describe Gitlab::Auth::AuthFinders do
context 'passed as header' do
it 'returns token if valid oauth_access_token' do
- env['HTTP_AUTHORIZATION'] = "Bearer #{token.token}"
+ set_header('HTTP_AUTHORIZATION', "Bearer #{token.token}")
expect(find_oauth_access_token.token).to eq token.token
end
@@ -329,7 +411,7 @@ describe Gitlab::Auth::AuthFinders do
end
it 'returns exception if invalid oauth_access_token' do
- env['HTTP_AUTHORIZATION'] = "Bearer invalid_token"
+ set_header('HTTP_AUTHORIZATION', "Bearer invalid_token")
expect { find_oauth_access_token }.to raise_error(Gitlab::Auth::UnauthorizedError)
end
@@ -337,7 +419,7 @@ describe Gitlab::Auth::AuthFinders do
describe '#find_personal_access_token_from_http_basic_auth' do
def auth_header_with(token)
- env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials('username', token)
+ set_basic_auth_header('username', token)
end
context 'access token is valid' do
@@ -384,14 +466,6 @@ describe Gitlab::Auth::AuthFinders do
end
describe '#find_user_from_basic_auth_job' do
- def basic_http_auth(username, password)
- ActionController::HttpAuthentication::Basic.encode_credentials(username, password)
- end
-
- def set_auth(username, password)
- env['HTTP_AUTHORIZATION'] = basic_http_auth(username, password)
- end
-
subject { find_user_from_basic_auth_job }
context 'when the request does not have AUTHORIZATION header' do
@@ -400,25 +474,25 @@ describe Gitlab::Auth::AuthFinders do
context 'with wrong credentials' do
it 'returns nil without user and password' do
- set_auth(nil, nil)
+ set_basic_auth_header(nil, nil)
is_expected.to be_nil
end
it 'returns nil without password' do
- set_auth('some-user', nil)
+ set_basic_auth_header('some-user', nil)
is_expected.to be_nil
end
it 'returns nil without user' do
- set_auth(nil, 'password')
+ set_basic_auth_header(nil, 'password')
is_expected.to be_nil
end
it 'returns nil without CI username' do
- set_auth('user', 'password')
+ set_basic_auth_header('user', 'password')
is_expected.to be_nil
end
@@ -430,19 +504,19 @@ describe Gitlab::Auth::AuthFinders do
let(:build) { create(:ci_build, user: user) }
it 'returns nil without password' do
- set_auth(username, nil)
+ set_basic_auth_header(username, nil)
is_expected.to be_nil
end
it 'returns user with valid token' do
- set_auth(username, build.token)
+ set_basic_auth_header(username, build.token)
is_expected.to eq user
end
it 'raises error with invalid token' do
- set_auth(username, 'token')
+ set_basic_auth_header(username, 'token')
expect { subject }.to raise_error(Gitlab::Auth::UnauthorizedError)
end
@@ -502,20 +576,20 @@ describe Gitlab::Auth::AuthFinders do
context 'when the job token is in the headers' do
it 'returns the user if valid job token' do
- env[described_class::JOB_TOKEN_HEADER] = job.token
+ set_header(described_class::JOB_TOKEN_HEADER, job.token)
is_expected.to eq(user)
expect(@current_authenticated_job).to eq(job)
end
it 'returns nil without job token' do
- env[described_class::JOB_TOKEN_HEADER] = ''
+ set_header(described_class::JOB_TOKEN_HEADER, '')
is_expected.to be_nil
end
it 'returns exception if invalid job token' do
- env[described_class::JOB_TOKEN_HEADER] = 'invalid token'
+ set_header(described_class::JOB_TOKEN_HEADER, 'invalid token')
expect { subject }.to raise_error(Gitlab::Auth::UnauthorizedError)
end
@@ -524,7 +598,7 @@ describe Gitlab::Auth::AuthFinders do
let(:route_authentication_setting) { { job_token_allowed: false } }
it 'sets current_user to nil' do
- env[described_class::JOB_TOKEN_HEADER] = job.token
+ set_header(described_class::JOB_TOKEN_HEADER, job.token)
allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(true)
@@ -586,7 +660,7 @@ describe Gitlab::Auth::AuthFinders do
context 'with API requests' do
before do
- env['SCRIPT_NAME'] = '/api/endpoint'
+ set_header('SCRIPT_NAME', '/api/endpoint')
end
it 'returns the runner if token is valid' do
@@ -614,7 +688,7 @@ describe Gitlab::Auth::AuthFinders do
context 'without API requests' do
before do
- env['SCRIPT_NAME'] = 'url.ics'
+ set_header('SCRIPT_NAME', 'url.ics')
end
it 'returns nil if token is valid' do
diff --git a/spec/lib/gitlab/auth/o_auth/provider_spec.rb b/spec/lib/gitlab/auth/o_auth/provider_spec.rb
index f46f9d76a1e..8b0d4d786cd 100644
--- a/spec/lib/gitlab/auth/o_auth/provider_spec.rb
+++ b/spec/lib/gitlab/auth/o_auth/provider_spec.rb
@@ -63,7 +63,7 @@ describe Gitlab::Auth::OAuth::Provider do
context 'for an OmniAuth provider' do
before do
provider = OpenStruct.new(
- name: 'google',
+ name: 'google_oauth2',
app_id: 'asd123',
app_secret: 'asd123'
)
@@ -71,8 +71,16 @@ describe Gitlab::Auth::OAuth::Provider do
end
context 'when the provider exists' do
+ subject { described_class.config_for('google_oauth2') }
+
it 'returns the config' do
- expect(described_class.config_for('google')).to be_a(OpenStruct)
+ expect(subject).to be_a(OpenStruct)
+ end
+
+ it 'merges defaults with the given configuration' do
+ defaults = Gitlab::OmniauthInitializer.default_arguments_for('google_oauth2').deep_stringify_keys
+
+ expect(subject['args']).to include(defaults)
end
end
diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb
index a0a8767637e..870f02b6933 100644
--- a/spec/lib/gitlab/auth_spec.rb
+++ b/spec/lib/gitlab/auth_spec.rb
@@ -715,6 +715,14 @@ describe Gitlab::Auth, :use_clean_rails_memory_store_caching do
end
end
+ describe ".resource_bot_scopes" do
+ subject { described_class.resource_bot_scopes }
+
+ it { is_expected.to include(*described_class::API_SCOPES - [:read_user]) }
+ it { is_expected.to include(*described_class::REPOSITORY_SCOPES) }
+ it { is_expected.to include(*described_class.registry_scopes) }
+ end
+
private
def expect_results_with_abilities(personal_access_token, abilities, success = true)
diff --git a/spec/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests_spec.rb b/spec/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests_spec.rb
new file mode 100644
index 00000000000..34ac70071bb
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::BackgroundMigration::BackfillEnvironmentIdDeploymentMergeRequests, schema: 20200312134637 do
+ let(:environments) { table(:environments) }
+ let(:merge_requests) { table(:merge_requests) }
+ let(:deployments) { table(:deployments) }
+ let(:deployment_merge_requests) { table(:deployment_merge_requests) }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+
+ subject(:migration) { described_class.new }
+
+ it 'correctly backfills environment_id column' do
+ namespace = namespaces.create!(name: 'foo', path: 'foo')
+ project = projects.create!(namespace_id: namespace.id)
+
+ production = environments.create!(project_id: project.id, name: 'production', slug: 'production')
+ staging = environments.create!(project_id: project.id, name: 'staging', slug: 'staging')
+
+ mr = merge_requests.create!(source_branch: 'x', target_branch: 'master', target_project_id: project.id)
+
+ deployment1 = deployments.create!(environment_id: staging.id, iid: 1, project_id: project.id, ref: 'master', tag: false, sha: '123abcdef', status: 1)
+ deployment2 = deployments.create!(environment_id: production.id, iid: 2, project_id: project.id, ref: 'master', tag: false, sha: '123abcdef', status: 1)
+ deployment3 = deployments.create!(environment_id: production.id, iid: 3, project_id: project.id, ref: 'master', tag: false, sha: '123abcdef', status: 1)
+
+ # mr is tracked twice in production through deployment2 and deployment3
+ deployment_merge_requests.create!(deployment_id: deployment1.id, merge_request_id: mr.id)
+ deployment_merge_requests.create!(deployment_id: deployment2.id, merge_request_id: mr.id)
+ deployment_merge_requests.create!(deployment_id: deployment3.id, merge_request_id: mr.id)
+
+ expect(deployment_merge_requests.where(environment_id: nil).count).to eq(3)
+
+ migration.backfill_range(1, mr.id)
+
+ expect(deployment_merge_requests.where(environment_id: nil).count).to be_zero
+ expect(deployment_merge_requests.count).to eq(2)
+
+ production_deployments = deployment_merge_requests.where(environment_id: production.id)
+ expect(production_deployments.count).to eq(1)
+ expect(production_deployments.first.deployment_id).to eq(deployment2.id)
+
+ expect(deployment_merge_requests.where(environment_id: staging.id).count).to eq(1)
+ end
+end
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 08d3b7bec6a..27ae60eb278 100644
--- a/spec/lib/gitlab/background_migration/backfill_snippet_repositories_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_snippet_repositories_spec.rb
@@ -2,13 +2,31 @@
require 'spec_helper'
-describe Gitlab::BackgroundMigration::BackfillSnippetRepositories, :migration, schema: 2020_02_26_162723 do
+describe Gitlab::BackgroundMigration::BackfillSnippetRepositories, :migration, schema: 2020_04_20_094444 do
let(:gitlab_shell) { Gitlab::Shell.new }
let(:users) { table(:users) }
let(:snippets) { table(:snippets) }
let(:snippet_repositories) { table(:snippet_repositories) }
- let(:user) { users.create(id: 1, email: 'user@example.com', projects_limit: 10, username: 'test', name: 'Test') }
+ let(:user_state) { 'active' }
+ let(:ghost) { false }
+ let(:user_type) { nil }
+ let(:user_name) { 'Test' }
+
+ let!(:user) do
+ users.create(id: 1,
+ email: 'user@example.com',
+ projects_limit: 10,
+ username: 'test',
+ name: user_name,
+ state: user_state,
+ ghost: ghost,
+ last_activity_on: 1.minute.ago,
+ user_type: user_type,
+ confirmed_at: 1.day.ago)
+ end
+
+ let(:migration_bot) { User.migration_bot }
let!(:snippet_with_repo) { snippets.create(id: 1, type: 'PersonalSnippet', author_id: user.id, file_name: file_name, content: content) }
let!(:snippet_with_empty_repo) { snippets.create(id: 2, type: 'PersonalSnippet', author_id: user.id, file_name: file_name, content: content) }
let!(:snippet_without_repo) { snippets.create(id: 3, type: 'PersonalSnippet', author_id: user.id, file_name: file_name, content: content) }
@@ -53,15 +71,52 @@ describe Gitlab::BackgroundMigration::BackfillSnippetRepositories, :migration, s
end
end
- shared_examples 'commits the file to the repository' do
+ shared_examples 'migration_bot user commits files' do
it do
subject
- blob = blob_at(snippet, file_name)
+ last_commit = raw_repository(snippet).commit
- aggregate_failures do
- expect(blob).to be
- expect(blob.data).to eq content
+ expect(last_commit.author_name).to eq migration_bot.name
+ expect(last_commit.author_email).to eq migration_bot.email
+ end
+ end
+
+ shared_examples 'commits the file to the repository' do
+ context 'when author can update snippet and use git' do
+ it 'creates the repository and commit the file' do
+ subject
+
+ blob = blob_at(snippet, file_name)
+ last_commit = raw_repository(snippet).commit
+
+ aggregate_failures do
+ expect(blob).to be
+ expect(blob.data).to eq content
+ expect(last_commit.author_name).to eq user.name
+ expect(last_commit.author_email).to eq user.email
+ end
+ end
+ end
+
+ context 'when author cannot update snippet or use git' do
+ context 'when user is blocked' do
+ let(:user_state) { 'blocked' }
+
+ it_behaves_like 'migration_bot user commits files'
+ end
+
+ context 'when user is deactivated' do
+ let(:user_state) { 'deactivated' }
+
+ it_behaves_like 'migration_bot user commits files'
+ end
+
+ context 'when user is a ghost' do
+ let(:ghost) { true }
+ let(:user_type) { 'ghost' }
+
+ it_behaves_like 'migration_bot user commits files'
end
end
end
@@ -123,6 +178,124 @@ describe Gitlab::BackgroundMigration::BackfillSnippetRepositories, :migration, s
end
end
end
+
+ context 'with invalid file names' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:invalid_file_name, :converted_file_name) do
+ 'filename.js // with comment' | 'filename-js-with-comment'
+ '.git/hooks/pre-commit' | 'git-hooks-pre-commit'
+ 'https://gitlab.com' | 'https-gitlab-com'
+ 'html://web.title%mp4/mpg/mpeg.net' | 'html-web-title-mp4-mpg-mpeg-net'
+ '../../etc/passwd' | 'etc-passwd'
+ '.' | 'snippetfile1.txt'
+ end
+
+ with_them do
+ let!(:snippet_with_invalid_path) { snippets.create(id: 4, type: 'PersonalSnippet', author_id: user.id, file_name: invalid_file_name, content: content) }
+ let!(:snippet_with_valid_path) { snippets.create(id: 5, type: 'PersonalSnippet', author_id: user.id, file_name: file_name, content: content) }
+ let(:ids) { [4, 5] }
+
+ after do
+ raw_repository(snippet_with_invalid_path).remove
+ raw_repository(snippet_with_valid_path).remove
+ end
+
+ it 'checks for file path errors when errors are raised' do
+ expect(service).to receive(:set_file_path_error).once.and_call_original
+
+ subject
+ end
+
+ it 'converts invalid filenames' do
+ subject
+
+ expect(blob_at(snippet_with_invalid_path, converted_file_name)).to be
+ end
+
+ it 'does not convert valid filenames on subsequent migrations' do
+ subject
+
+ expect(blob_at(snippet_with_valid_path, file_name)).to be
+ end
+ end
+ end
+
+ context 'when snippet content size is higher than the existing limit' do
+ let(:limit) { 15 }
+ let(:content) { 'a' * (limit + 1) }
+ let(:snippet) { snippet_without_repo }
+ let(:ids) { [snippet.id, snippet.id] }
+
+ before do
+ allow(Gitlab::CurrentSettings).to receive(:snippet_size_limit).and_return(limit)
+ end
+
+ it_behaves_like 'migration_bot user commits files'
+ end
+
+ context 'when user name is invalid' do
+ let(:user_name) { '.' }
+ let!(:snippet) { snippets.create(id: 4, type: 'PersonalSnippet', author_id: user.id, file_name: file_name, content: content) }
+ let(:ids) { [4, 4] }
+
+ after do
+ raw_repository(snippet).remove
+ end
+
+ it_behaves_like 'migration_bot user commits files'
+ end
+
+ context 'when both user name and snippet file_name are invalid' do
+ let(:user_name) { '.' }
+ let!(:other_user) do
+ users.create(id: 2,
+ email: 'user2@example.com',
+ projects_limit: 10,
+ username: 'test2',
+ name: 'Test2',
+ state: user_state,
+ ghost: ghost,
+ last_activity_on: 1.minute.ago,
+ user_type: user_type,
+ confirmed_at: 1.day.ago)
+ end
+ let!(:invalid_snippet) { snippets.create(id: 4, type: 'PersonalSnippet', author_id: user.id, file_name: '.', content: content) }
+ let!(:snippet) { snippets.create(id: 5, type: 'PersonalSnippet', author_id: other_user.id, file_name: file_name, content: content) }
+ let(:ids) { [4, 5] }
+
+ after do
+ raw_repository(snippet).remove
+ raw_repository(invalid_snippet).remove
+ end
+
+ it 'updates the file_name only when it is invalid' do
+ subject
+
+ expect(blob_at(invalid_snippet, 'snippetfile1.txt')).to be
+ expect(blob_at(snippet, file_name)).to be
+ end
+
+ it_behaves_like 'migration_bot user commits files' do
+ let(:snippet) { invalid_snippet }
+ end
+
+ it 'does not alter the commit author in subsequent migrations' do
+ subject
+
+ last_commit = raw_repository(snippet).commit
+
+ expect(last_commit.author_name).to eq other_user.name
+ expect(last_commit.author_email).to eq other_user.email
+ end
+
+ it "increases the number of retries temporarily from #{described_class::MAX_RETRIES} to #{described_class::MAX_RETRIES + 1}" do
+ expect(service).to receive(:create_commit).with(Snippet.find(invalid_snippet.id)).exactly(described_class::MAX_RETRIES + 1).times.and_call_original
+ expect(service).to receive(:create_commit).with(Snippet.find(snippet.id)).once.and_call_original
+
+ subject
+ end
+ end
end
def blob_at(snippet, path)
diff --git a/spec/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data_spec.rb b/spec/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data_spec.rb
index 7dae28f72a5..4411dca3fd9 100644
--- a/spec/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data_spec.rb
+++ b/spec/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data_spec.rb
@@ -4,40 +4,45 @@ require 'spec_helper'
describe Gitlab::BackgroundMigration::MigrateIssueTrackersSensitiveData, schema: 20200130145430 do
let(:services) { table(:services) }
- # we need to define the classes due to encryption
- class IssueTrackerData < ApplicationRecord
- self.table_name = 'issue_tracker_data'
-
- def self.encryption_options
- {
- key: Settings.attr_encrypted_db_key_base_32,
- encode: true,
- mode: :per_attribute_iv,
- algorithm: 'aes-256-gcm'
- }
+ before do
+ # we need to define the classes due to encryption
+ issue_tracker_data = Class.new(ApplicationRecord) do
+ self.table_name = 'issue_tracker_data'
+
+ def self.encryption_options
+ {
+ key: Settings.attr_encrypted_db_key_base_32,
+ encode: true,
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-gcm'
+ }
+ end
+
+ attr_encrypted :project_url, encryption_options
+ attr_encrypted :issues_url, encryption_options
+ attr_encrypted :new_issue_url, encryption_options
end
- attr_encrypted :project_url, encryption_options
- attr_encrypted :issues_url, encryption_options
- attr_encrypted :new_issue_url, encryption_options
- end
+ jira_tracker_data = Class.new(ApplicationRecord) do
+ self.table_name = 'jira_tracker_data'
- class JiraTrackerData < ApplicationRecord
- self.table_name = 'jira_tracker_data'
+ def self.encryption_options
+ {
+ key: Settings.attr_encrypted_db_key_base_32,
+ encode: true,
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-gcm'
+ }
+ end
- def self.encryption_options
- {
- key: Settings.attr_encrypted_db_key_base_32,
- encode: true,
- mode: :per_attribute_iv,
- algorithm: 'aes-256-gcm'
- }
+ attr_encrypted :url, encryption_options
+ attr_encrypted :api_url, encryption_options
+ attr_encrypted :username, encryption_options
+ attr_encrypted :password, encryption_options
end
- attr_encrypted :url, encryption_options
- attr_encrypted :api_url, encryption_options
- attr_encrypted :username, encryption_options
- attr_encrypted :password, encryption_options
+ stub_const('IssueTrackerData', issue_tracker_data)
+ stub_const('JiraTrackerData', jira_tracker_data)
end
let(:url) { 'http://base-url.tracker.com' }
@@ -90,7 +95,7 @@ describe Gitlab::BackgroundMigration::MigrateIssueTrackersSensitiveData, schema:
end
end
- context 'with jira service' do
+ context 'with Jira service' do
let!(:service) do
services.create(id: 10, type: 'JiraService', title: nil, properties: jira_properties.to_json, category: 'issue_tracker')
end
@@ -202,7 +207,7 @@ describe Gitlab::BackgroundMigration::MigrateIssueTrackersSensitiveData, schema:
end
end
- context 'with jira service which has data fields record inconsistent with properties field' do
+ context 'with Jira service which has data fields record inconsistent with properties field' do
let!(:service) do
services.create(id: 16, type: 'CustomIssueTrackerService', description: 'Existing description', properties: jira_properties.to_json, category: 'issue_tracker').tap do |service|
JiraTrackerData.create!(service_id: service.id, url: 'http://other_jira_url')
@@ -241,7 +246,7 @@ describe Gitlab::BackgroundMigration::MigrateIssueTrackersSensitiveData, schema:
end
end
- context 'jira service with empty properties' do
+ context 'Jira service with empty properties' do
let!(:service) do
services.create(id: 18, type: 'JiraService', properties: '', category: 'issue_tracker')
end
@@ -253,7 +258,7 @@ describe Gitlab::BackgroundMigration::MigrateIssueTrackersSensitiveData, schema:
end
end
- context 'jira service with nil properties' do
+ context 'Jira service with nil properties' do
let!(:service) do
services.create(id: 18, type: 'JiraService', properties: nil, category: 'issue_tracker')
end
@@ -265,7 +270,7 @@ describe Gitlab::BackgroundMigration::MigrateIssueTrackersSensitiveData, schema:
end
end
- context 'jira service with invalid properties' do
+ context 'Jira service with invalid properties' do
let!(:service) do
services.create(id: 18, type: 'JiraService', properties: 'invalid data', category: 'issue_tracker')
end
@@ -277,7 +282,7 @@ describe Gitlab::BackgroundMigration::MigrateIssueTrackersSensitiveData, schema:
end
end
- context 'with jira service with invalid properties, valid jira service and valid bugzilla service' do
+ context 'with Jira service with invalid properties, valid Jira service and valid bugzilla service' do
let!(:jira_service_invalid) do
services.create(id: 19, title: 'invalid - title', description: 'invalid - description', type: 'JiraService', properties: 'invalid data', category: 'issue_tracker')
end
diff --git a/spec/lib/gitlab/chat/responder/mattermost_spec.rb b/spec/lib/gitlab/chat/responder/mattermost_spec.rb
new file mode 100644
index 00000000000..f3480dfef06
--- /dev/null
+++ b/spec/lib/gitlab/chat/responder/mattermost_spec.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Chat::Responder::Mattermost do
+ let(:chat_name) { create(:chat_name, chat_id: 'U123') }
+
+ let(:pipeline) do
+ pipeline = create(:ci_pipeline)
+
+ pipeline.create_chat_data!(
+ response_url: 'http://example.com',
+ chat_name_id: chat_name.id
+ )
+
+ pipeline
+ end
+
+ let(:build) { create(:ci_build, pipeline: pipeline) }
+ let(:responder) { described_class.new(build) }
+
+ describe '#send_response' do
+ it 'sends a response back to Slack' do
+ expect(Gitlab::HTTP).to receive(:post).with(
+ 'http://example.com',
+ { headers: { 'Content-Type': 'application/json' }, body: 'hello'.to_json }
+ )
+
+ responder.send_response('hello')
+ end
+ end
+
+ describe '#success' do
+ it 'returns the output for a successful build' do
+ expect(responder)
+ .to receive(:send_response)
+ .with(
+ hash_including(
+ response_type: :in_channel,
+ attachments: array_including(
+ a_hash_including(
+ text: /#{pipeline.chat_data.chat_name.user.name}.*completed successfully/,
+ fields: array_including(
+ a_hash_including(value: /##{build.id}/),
+ a_hash_including(value: build.name),
+ a_hash_including(value: "```shell\nscript output\n```")
+ )
+ )
+ )
+ )
+ )
+
+ responder.success('script output')
+ end
+
+ it 'limits the output to a fixed size' do
+ expect(responder)
+ .to receive(:send_response)
+ .with(
+ hash_including(
+ response_type: :in_channel,
+ attachments: array_including(
+ a_hash_including(
+ fields: array_including(
+ a_hash_including(value: /The output is too large/)
+ )
+ )
+ )
+ )
+ )
+
+ responder.success('a' * 4000)
+ end
+
+ it 'does not send a response if the output is empty' do
+ expect(responder).not_to receive(:send_response)
+
+ responder.success('')
+ end
+ end
+
+ describe '#failure' do
+ it 'returns the output for a failed build' do
+ expect(responder)
+ .to receive(:send_response)
+ .with(
+ hash_including(
+ response_type: :in_channel,
+ attachments: array_including(
+ a_hash_including(
+ text: /#{pipeline.chat_data.chat_name.user.name}.*failed/,
+ fields: array_including(
+ a_hash_including(value: /##{build.id}/),
+ a_hash_including(value: build.name)
+ )
+ )
+ )
+ )
+ )
+
+ responder.failure
+ end
+ end
+
+ describe '#scheduled_output' do
+ it 'returns the output for a scheduled build' do
+ output = responder.scheduled_output
+
+ expect(output).to match(
+ hash_including(
+ response_type: :ephemeral,
+ text: /##{build.id}/
+ )
+ )
+ end
+ end
+end
diff --git a/spec/lib/gitlab/checks/push_file_count_check_spec.rb b/spec/lib/gitlab/checks/push_file_count_check_spec.rb
index 58ba7d579a3..e05102a9ce8 100644
--- a/spec/lib/gitlab/checks/push_file_count_check_spec.rb
+++ b/spec/lib/gitlab/checks/push_file_count_check_spec.rb
@@ -8,7 +8,7 @@ describe Gitlab::Checks::PushFileCountCheck do
let(:timeout) { Gitlab::GitAccess::INTERNAL_TIMEOUT }
let(:logger) { Gitlab::Checks::TimedLogger.new(timeout: timeout) }
- subject { described_class.new(changes, repository: snippet.repository, limit: 1, logger: logger) }
+ subject { described_class.new(changes, repository: snippet.repository, limit: 2, logger: logger) }
describe '#validate!' do
using RSpec::Parameterized::TableSyntax
@@ -31,7 +31,7 @@ describe Gitlab::Checks::PushFileCountCheck do
where(:old, :new, :valid, :message) do
'single-file' | 'edit-file' | true | nil
- 'single-file' | 'multiple-files' | false | 'The repository can contain at most 1 file(s).'
+ 'single-file' | 'multiple-files' | false | 'The repository can contain at most 2 file(s).'
'single-file' | 'no-files' | false | 'The repository must contain at least 1 file.'
'edit-file' | 'rename-and-edit-file' | true | nil
end
diff --git a/spec/lib/gitlab/ci/config/entry/artifacts_spec.rb b/spec/lib/gitlab/ci/config/entry/artifacts_spec.rb
index 513a9b8f2b4..8cfd07df777 100644
--- a/spec/lib/gitlab/ci/config/entry/artifacts_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/artifacts_spec.rb
@@ -123,25 +123,53 @@ describe Gitlab::Ci::Config::Entry::Artifacts do
end
end
end
+ end
- context 'when feature flag :ci_expose_arbitrary_artifacts_in_mr is disabled' do
+ describe 'excluded artifacts' do
+ context 'when configuration is valid and the feature is enabled' do
before do
- stub_feature_flags(ci_expose_arbitrary_artifacts_in_mr: false)
+ stub_feature_flags(ci_artifacts_exclude: true)
end
- context 'when syntax is correct' do
- let(:config) { { expose_as: 'Test results', paths: ['test.txt'] } }
+ context 'when configuration is valid' do
+ let(:config) { { untracked: true, exclude: ['some/directory/'] } }
- it 'is valid' do
- expect(entry.errors).to be_empty
+ it 'correctly parses the configuration' do
+ expect(entry).to be_valid
+ expect(entry.value).to eq config
end
end
- context 'when syntax for :expose_as is incorrect' do
- let(:config) { { paths: %w[results.txt], expose_as: '' } }
+ context 'when configuration is not valid' do
+ let(:config) { { untracked: true, exclude: 1234 } }
+
+ it 'returns an error' do
+ expect(entry).not_to be_valid
+ expect(entry.errors)
+ .to include 'artifacts exclude should be an array of strings'
+ end
+ end
+ end
+
+ context 'when artifacts/exclude feature is disabled' do
+ before do
+ stub_feature_flags(ci_artifacts_exclude: false)
+ end
+
+ context 'when configuration has been provided' do
+ let(:config) { { untracked: true, exclude: ['some/directory/'] } }
+
+ it 'returns an error' do
+ expect(entry).not_to be_valid
+ expect(entry.errors).to include 'artifacts exclude feature is disabled'
+ end
+ end
+
+ context 'when configuration is not present' do
+ let(:config) { { untracked: true } }
- it 'is valid' do
- expect(entry.errors).to be_empty
+ it 'is a valid configuration' do
+ expect(entry).to be_valid
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/reports_spec.rb b/spec/lib/gitlab/ci/config/entry/reports_spec.rb
index 9bba3eb2b77..8c6c91d919e 100644
--- a/spec/lib/gitlab/ci/config/entry/reports_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/reports_spec.rb
@@ -47,6 +47,8 @@ describe Gitlab::Ci::Config::Entry::Reports do
:dotenv | 'build.dotenv'
:cobertura | 'cobertura-coverage.xml'
:terraform | 'tfplan.json'
+ :accessibility | 'gl-accessibility.json'
+ :cluster_applications | 'gl-cluster-applications.json'
end
with_them do
diff --git a/spec/lib/gitlab/ci/config/entry/trigger_spec.rb b/spec/lib/gitlab/ci/config/entry/trigger_spec.rb
index 752c3f59a95..dfd9807583c 100644
--- a/spec/lib/gitlab/ci/config/entry/trigger_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/trigger_spec.rb
@@ -114,19 +114,6 @@ describe Gitlab::Ci::Config::Entry::Trigger do
.to match /config contains unknown keys: branch/
end
end
-
- context 'when feature flag is off' do
- before do
- stub_feature_flags(ci_parent_child_pipeline: false)
- end
-
- let(:config) { { include: 'path/to/config.yml' } }
-
- it 'is returns an error if include is used' do
- expect(subject.errors.first)
- .to match /config must specify project/
- end
- end
end
context 'when config contains unknown keys' do
diff --git a/spec/lib/gitlab/ci/cron_parser_spec.rb b/spec/lib/gitlab/ci/cron_parser_spec.rb
index 385df72fa41..8f9f3d7fa37 100644
--- a/spec/lib/gitlab/ci/cron_parser_spec.rb
+++ b/spec/lib/gitlab/ci/cron_parser_spec.rb
@@ -7,198 +7,240 @@ describe Gitlab::Ci::CronParser do
it { is_expected.to be > Time.now }
end
- describe '#next_time_from' do
- subject { described_class.new(cron, cron_timezone).next_time_from(Time.now) }
+ shared_examples_for "returns time in the past" do
+ it { is_expected.to be < Time.now }
+ end
- context 'when cron and cron_timezone are valid' do
- context 'when specific time' do
- let(:cron) { '3 4 5 6 *' }
- let(:cron_timezone) { 'UTC' }
+ shared_examples_for 'when cron and cron_timezone are valid' do |returns_time_for_epoch|
+ context 'when specific time' do
+ let(:cron) { '3 4 5 6 *' }
+ let(:cron_timezone) { 'UTC' }
- it_behaves_like "returns time in the future"
+ it_behaves_like returns_time_for_epoch
- it 'returns exact time' do
- expect(subject.min).to eq(3)
- expect(subject.hour).to eq(4)
- expect(subject.day).to eq(5)
- expect(subject.month).to eq(6)
- end
+ it 'returns exact time' do
+ expect(subject.min).to eq(3)
+ expect(subject.hour).to eq(4)
+ expect(subject.day).to eq(5)
+ expect(subject.month).to eq(6)
end
+ end
- context 'when specific day of week' do
- let(:cron) { '* * * * 0' }
- let(:cron_timezone) { 'UTC' }
+ context 'when specific day of week' do
+ let(:cron) { '* * * * 0' }
+ let(:cron_timezone) { 'UTC' }
- it_behaves_like "returns time in the future"
+ it_behaves_like returns_time_for_epoch
- it 'returns exact day of week' do
- expect(subject.wday).to eq(0)
- end
+ it 'returns exact day of week' do
+ expect(subject.wday).to eq(0)
end
+ end
- context 'when slash used' do
- let(:cron) { '*/10 */6 */10 */10 *' }
- let(:cron_timezone) { 'UTC' }
+ context 'when slash used' do
+ let(:cron) { '*/10 */6 */10 */10 *' }
+ let(:cron_timezone) { 'UTC' }
- it_behaves_like "returns time in the future"
+ it_behaves_like returns_time_for_epoch
- it 'returns specific time' do
- expect(subject.min).to be_in([0, 10, 20, 30, 40, 50])
- expect(subject.hour).to be_in([0, 6, 12, 18])
- expect(subject.day).to be_in([1, 11, 21, 31])
- expect(subject.month).to be_in([1, 11])
- end
+ it 'returns specific time' do
+ expect(subject.min).to be_in([0, 10, 20, 30, 40, 50])
+ expect(subject.hour).to be_in([0, 6, 12, 18])
+ expect(subject.day).to be_in([1, 11, 21, 31])
+ expect(subject.month).to be_in([1, 11])
end
+ end
- context 'when range used' do
- let(:cron) { '0,20,40 * 1-5 * *' }
- let(:cron_timezone) { 'UTC' }
+ context 'when range used' do
+ let(:cron) { '0,20,40 * 1-5 * *' }
+ let(:cron_timezone) { 'UTC' }
- it_behaves_like "returns time in the future"
+ it_behaves_like returns_time_for_epoch
- it 'returns specific time' do
- expect(subject.min).to be_in([0, 20, 40])
- expect(subject.day).to be_in((1..5).to_a)
- end
+ it 'returns specific time' do
+ expect(subject.min).to be_in([0, 20, 40])
+ expect(subject.day).to be_in((1..5).to_a)
end
+ end
- context 'when cron_timezone is TZInfo format' do
- before do
- allow(Time).to receive(:zone)
- .and_return(ActiveSupport::TimeZone['UTC'])
- end
+ context 'when cron_timezone is TZInfo format' do
+ before do
+ allow(Time).to receive(:zone)
+ .and_return(ActiveSupport::TimeZone['UTC'])
+ end
- let(:hour_in_utc) do
- ActiveSupport::TimeZone[cron_timezone]
- .now.change(hour: 0).in_time_zone('UTC').hour
- end
+ let(:hour_in_utc) do
+ ActiveSupport::TimeZone[cron_timezone]
+ .now.change(hour: 0).in_time_zone('UTC').hour
+ end
- context 'when cron_timezone is US/Pacific' do
- let(:cron) { '* 0 * * *' }
- let(:cron_timezone) { 'US/Pacific' }
+ context 'when cron_timezone is US/Pacific' do
+ let(:cron) { '* 0 * * *' }
+ let(:cron_timezone) { 'US/Pacific' }
- it_behaves_like "returns time in the future"
+ it_behaves_like returns_time_for_epoch
- context 'when PST (Pacific Standard Time)' do
- it 'converts time in server time zone' do
- Timecop.freeze(Time.utc(2017, 1, 1)) do
- expect(subject.hour).to eq(hour_in_utc)
- end
+ context 'when PST (Pacific Standard Time)' do
+ it 'converts time in server time zone' do
+ Timecop.freeze(Time.utc(2017, 1, 1)) do
+ expect(subject.hour).to eq(hour_in_utc)
end
end
+ end
- context 'when PDT (Pacific Daylight Time)' do
- it 'converts time in server time zone' do
- Timecop.freeze(Time.utc(2017, 6, 1)) do
- expect(subject.hour).to eq(hour_in_utc)
- end
+ context 'when PDT (Pacific Daylight Time)' do
+ it 'converts time in server time zone' do
+ Timecop.freeze(Time.utc(2017, 6, 1)) do
+ expect(subject.hour).to eq(hour_in_utc)
end
end
end
end
+ end
- context 'when cron_timezone is ActiveSupport::TimeZone format' do
- before do
- allow(Time).to receive(:zone)
- .and_return(ActiveSupport::TimeZone['UTC'])
- end
+ context 'when cron_timezone is ActiveSupport::TimeZone format' do
+ before do
+ allow(Time).to receive(:zone)
+ .and_return(ActiveSupport::TimeZone['UTC'])
+ end
- let(:hour_in_utc) do
- ActiveSupport::TimeZone[cron_timezone]
- .now.change(hour: 0).in_time_zone('UTC').hour
- end
+ let(:hour_in_utc) do
+ ActiveSupport::TimeZone[cron_timezone]
+ .now.change(hour: 0).in_time_zone('UTC').hour
+ end
- context 'when cron_timezone is Berlin' do
- let(:cron) { '* 0 * * *' }
- let(:cron_timezone) { 'Berlin' }
+ context 'when cron_timezone is Berlin' do
+ let(:cron) { '* 0 * * *' }
+ let(:cron_timezone) { 'Berlin' }
- it_behaves_like "returns time in the future"
+ it_behaves_like returns_time_for_epoch
- context 'when CET (Central European Time)' do
- it 'converts time in server time zone' do
- Timecop.freeze(Time.utc(2017, 1, 1)) do
- expect(subject.hour).to eq(hour_in_utc)
- end
+ context 'when CET (Central European Time)' do
+ it 'converts time in server time zone' do
+ Timecop.freeze(Time.utc(2017, 1, 1)) do
+ expect(subject.hour).to eq(hour_in_utc)
end
end
+ end
- context 'when CEST (Central European Summer Time)' do
- it 'converts time in server time zone' do
- Timecop.freeze(Time.utc(2017, 6, 1)) do
- expect(subject.hour).to eq(hour_in_utc)
- end
+ context 'when CEST (Central European Summer Time)' do
+ it 'converts time in server time zone' do
+ Timecop.freeze(Time.utc(2017, 6, 1)) do
+ expect(subject.hour).to eq(hour_in_utc)
end
end
end
+ end
+ end
+ end
- context 'when cron_timezone is Eastern Time (US & Canada)' do
- let(:cron) { '* 0 * * *' }
- let(:cron_timezone) { 'Eastern Time (US & Canada)' }
+ shared_examples_for 'when cron_timezone is Eastern Time (US & Canada)' do |returns_time_for_epoch, year|
+ let(:cron) { '* 0 * * *' }
+ let(:cron_timezone) { 'Eastern Time (US & Canada)' }
- it_behaves_like "returns time in the future"
+ before do
+ allow(Time).to receive(:zone)
+ .and_return(ActiveSupport::TimeZone['UTC'])
+ end
- context 'when EST (Eastern Standard Time)' do
- it 'converts time in server time zone' do
- Timecop.freeze(Time.utc(2017, 1, 1)) do
- expect(subject.hour).to eq(hour_in_utc)
- end
- end
- end
+ let(:hour_in_utc) do
+ ActiveSupport::TimeZone[cron_timezone]
+ .now.change(hour: 0).in_time_zone('UTC').hour
+ end
- context 'when EDT (Eastern Daylight Time)' do
- it 'converts time in server time zone' do
- Timecop.freeze(Time.utc(2017, 6, 1)) do
- expect(subject.hour).to eq(hour_in_utc)
- end
- end
- end
+ it_behaves_like returns_time_for_epoch
- context 'when time crosses a Daylight Savings boundary' do
- let(:cron) { '* 0 1 12 *'}
-
- # Note this previously only failed if the time zone is set
- # to a zone that observes Daylight Savings
- # (e.g. America/Chicago) at the start of the test. Stubbing
- # TZ doesn't appear to be enough.
- it 'generates day without TZInfo::AmbiguousTime error' do
- Timecop.freeze(Time.utc(2020, 1, 1)) do
- expect(subject.year).to eq(2020)
- expect(subject.month).to eq(12)
- expect(subject.day).to eq(1)
- end
- end
- end
+ context 'when EST (Eastern Standard Time)' do
+ it 'converts time in server time zone' do
+ Timecop.freeze(Time.utc(2017, 1, 1)) do
+ expect(subject.hour).to eq(hour_in_utc)
end
end
end
- context 'when cron and cron_timezone are invalid' do
- let(:cron) { 'invalid_cron' }
- let(:cron_timezone) { 'invalid_cron_timezone' }
+ context 'when EDT (Eastern Daylight Time)' do
+ it 'converts time in server time zone' do
+ Timecop.freeze(Time.utc(2017, 6, 1)) do
+ expect(subject.hour).to eq(hour_in_utc)
+ end
+ end
+ end
- it { is_expected.to be_nil }
+ context 'when time crosses a Daylight Savings boundary' do
+ let(:cron) { '* 0 1 12 *'}
+
+ # Note this previously only failed if the time zone is set
+ # to a zone that observes Daylight Savings
+ # (e.g. America/Chicago) at the start of the test. Stubbing
+ # TZ doesn't appear to be enough.
+ it 'generates day without TZInfo::AmbiguousTime error' do
+ Timecop.freeze(Time.utc(2020, 1, 1)) do
+ expect(subject.year).to eq(year)
+ expect(subject.month).to eq(12)
+ expect(subject.day).to eq(1)
+ end
+ end
end
+ end
- context 'when cron syntax is quoted' do
- let(:cron) { "'0 * * * *'" }
- let(:cron_timezone) { 'UTC' }
+ shared_examples_for 'when cron and cron_timezone are invalid' do
+ let(:cron) { 'invalid_cron' }
+ let(:cron_timezone) { 'invalid_cron_timezone' }
- it { expect(subject).to be_nil }
- end
+ it { is_expected.to be_nil }
+ end
- context 'when cron syntax is rufus-scheduler syntax' do
- let(:cron) { 'every 3h' }
- let(:cron_timezone) { 'UTC' }
+ shared_examples_for 'when cron syntax is quoted' do
+ let(:cron) { "'0 * * * *'" }
+ let(:cron_timezone) { 'UTC' }
- it { expect(subject).to be_nil }
- end
+ it { expect(subject).to be_nil }
+ end
- context 'when cron is scheduled to a non existent day' do
- let(:cron) { '0 12 31 2 *' }
- let(:cron_timezone) { 'UTC' }
+ shared_examples_for 'when cron syntax is rufus-scheduler syntax' do
+ let(:cron) { 'every 3h' }
+ let(:cron_timezone) { 'UTC' }
- it { expect(subject).to be_nil }
- end
+ it { expect(subject).to be_nil }
+ end
+
+ shared_examples_for 'when cron is scheduled to a non existent day' do
+ let(:cron) { '0 12 31 2 *' }
+ let(:cron_timezone) { 'UTC' }
+
+ it { expect(subject).to be_nil }
+ end
+
+ describe '#next_time_from' do
+ subject { described_class.new(cron, cron_timezone).next_time_from(Time.now) }
+
+ it_behaves_like 'when cron and cron_timezone are valid', 'returns time in the future'
+
+ it_behaves_like 'when cron_timezone is Eastern Time (US & Canada)', 'returns time in the future', 2020
+
+ it_behaves_like 'when cron and cron_timezone are invalid'
+
+ it_behaves_like 'when cron syntax is quoted'
+
+ it_behaves_like 'when cron syntax is rufus-scheduler syntax'
+
+ it_behaves_like 'when cron is scheduled to a non existent day'
+ end
+
+ describe '#previous_time_from' do
+ subject { described_class.new(cron, cron_timezone).previous_time_from(Time.now) }
+
+ it_behaves_like 'when cron and cron_timezone are valid', 'returns time in the past'
+
+ it_behaves_like 'when cron_timezone is Eastern Time (US & Canada)', 'returns time in the past', 2019
+
+ it_behaves_like 'when cron and cron_timezone are invalid'
+
+ it_behaves_like 'when cron syntax is quoted'
+
+ it_behaves_like 'when cron syntax is rufus-scheduler syntax'
+
+ it_behaves_like 'when cron is scheduled to a non existent day'
end
describe '#cron_valid?' do
diff --git a/spec/lib/gitlab/ci/parsers/accessibility/pa11y_spec.rb b/spec/lib/gitlab/ci/parsers/accessibility/pa11y_spec.rb
new file mode 100644
index 00000000000..4d87e3b201a
--- /dev/null
+++ b/spec/lib/gitlab/ci/parsers/accessibility/pa11y_spec.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+describe Gitlab::Ci::Parsers::Accessibility::Pa11y do
+ describe '#parse!' do
+ subject { described_class.new.parse!(pa11y, accessibility_report) }
+
+ let(:accessibility_report) { Gitlab::Ci::Reports::AccessibilityReports.new }
+
+ context "when data is pa11y style JSON" do
+ context "when there are no URLs provided" do
+ let(:pa11y) do
+ {
+ "total": 1,
+ "passes": 0,
+ "errors": 0,
+ "results": {
+ "": [
+ {
+ "message": "Protocol error (Page.navigate): Cannot navigate to invalid URL"
+ }
+ ]
+ }
+ }.to_json
+ end
+
+ it "returns an accessibility report" do
+ expect { subject }.not_to raise_error
+
+ expect(accessibility_report.errors_count).to eq(0)
+ expect(accessibility_report.passes_count).to eq(0)
+ expect(accessibility_report.scans_count).to eq(0)
+ expect(accessibility_report.urls).to be_empty
+ expect(accessibility_report.error_message).to eq("Empty URL detected in gl-accessibility.json")
+ end
+ end
+
+ context "when there are no errors" do
+ let(:pa11y) do
+ {
+ "total": 1,
+ "passes": 1,
+ "errors": 0,
+ "results": {
+ "http://pa11y.org/": []
+ }
+ }.to_json
+ end
+
+ it "returns an accessibility report" do
+ expect { subject }.not_to raise_error
+
+ expect(accessibility_report.urls['http://pa11y.org/']).to be_empty
+ expect(accessibility_report.errors_count).to eq(0)
+ expect(accessibility_report.passes_count).to eq(1)
+ expect(accessibility_report.scans_count).to eq(1)
+ end
+ end
+
+ context "when there are errors" do
+ let(:pa11y) do
+ {
+ "total": 1,
+ "passes": 0,
+ "errors": 1,
+ "results": {
+ "https://about.gitlab.com/": [
+ {
+ "code": "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent",
+ "type": "error",
+ "typeCode": 1,
+ "message": "Anchor element found with a valid href attribute, but no link content has been supplied.",
+ "context": "<a href=\"/\" class=\"navbar-brand animated\"><svg height=\"36\" viewBox=\"0 0 1...</a>",
+ "selector": "#main-nav > div:nth-child(1) > a",
+ "runner": "htmlcs",
+ "runnerExtras": {}
+ }
+ ]
+ }
+ }.to_json
+ end
+
+ it "returns an accessibility report" do
+ expect { subject }.not_to raise_error
+
+ expect(accessibility_report.errors_count).to eq(1)
+ expect(accessibility_report.passes_count).to eq(0)
+ expect(accessibility_report.scans_count).to eq(1)
+ expect(accessibility_report.urls['https://about.gitlab.com/']).to be_present
+ expect(accessibility_report.urls['https://about.gitlab.com/'].first[:code]).to be_present
+ end
+ end
+ end
+
+ context "when data is not a valid JSON string" do
+ let(:pa11y) do
+ {
+ "total": 1,
+ "passes": 1,
+ "errors": 0,
+ "results": {
+ "http://pa11y.org/": []
+ }
+ }
+ end
+
+ it "sets error_message" do
+ expect { subject }.not_to raise_error
+
+ expect(accessibility_report.error_message).to include('Pa11y parsing failed')
+ expect(accessibility_report.errors_count).to eq(0)
+ expect(accessibility_report.passes_count).to eq(0)
+ expect(accessibility_report.scans_count).to eq(0)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/parsers/terraform/tfplan_spec.rb b/spec/lib/gitlab/ci/parsers/terraform/tfplan_spec.rb
new file mode 100644
index 00000000000..19cd75e586c
--- /dev/null
+++ b/spec/lib/gitlab/ci/parsers/terraform/tfplan_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Ci::Parsers::Terraform::Tfplan do
+ describe '#parse!' do
+ let_it_be(:artifact) { create(:ci_job_artifact, :terraform) }
+
+ let(:reports) { Gitlab::Ci::Reports::TerraformReports.new }
+
+ context 'when data is tfplan.json' do
+ context 'when there is no data' do
+ it 'raises an error' do
+ plan = '{}'
+
+ expect { subject.parse!(plan, reports, artifact: artifact) }.to raise_error(
+ described_class::TfplanParserError
+ )
+ end
+ end
+
+ context 'when there is data' do
+ it 'parses JSON and returns a report' do
+ plan = '{ "create": 0, "update": 1, "delete": 0 }'
+
+ expect { subject.parse!(plan, reports, artifact: artifact) }.not_to raise_error
+
+ expect(reports.plans).to match(
+ a_hash_including(
+ 'tfplan.json' => a_hash_including(
+ 'create' => 0,
+ 'update' => 1,
+ 'delete' => 0
+ )
+ )
+ )
+ end
+ end
+ end
+
+ context 'when data is not tfplan.json' do
+ it 'raises an error' do
+ plan = { 'create' => 0, 'update' => 1, 'delete' => 0 }.to_s
+
+ expect { subject.parse!(plan, reports, artifact: artifact) }.to raise_error(
+ described_class::TfplanParserError
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/parsers/test/junit_spec.rb b/spec/lib/gitlab/ci/parsers/test/junit_spec.rb
index b4be5a41cd7..7b7ace02bba 100644
--- a/spec/lib/gitlab/ci/parsers/test/junit_spec.rb
+++ b/spec/lib/gitlab/ci/parsers/test/junit_spec.rb
@@ -215,8 +215,64 @@ describe Gitlab::Ci::Parsers::Test::Junit do
context 'when data is not JUnit style XML' do
let(:junit) { { testsuite: 'abc' }.to_json }
- it 'raises an error' do
- expect { subject }.to raise_error(described_class::JunitParserError)
+ it 'attaches an error to the TestSuite object' do
+ expect { subject }.not_to raise_error
+ expect(test_cases).to be_empty
+ end
+ end
+
+ context 'when data is malformed JUnit XML' do
+ let(:junit) do
+ <<-EOF.strip_heredoc
+ <testsuite>
+ <testcase classname='Calculator' name='sumTest1' time='0.01'></testcase>
+ <testcase classname='Calculator' name='sumTest2' time='0.02'></testcase
+ </testsuite>
+ EOF
+ end
+
+ it 'attaches an error to the TestSuite object' do
+ expect { subject }.not_to raise_error
+ expect(test_suite.suite_error).to eq("JUnit XML parsing failed: 4:1: FATAL: expected '>'")
+ end
+
+ it 'returns 0 tests cases' do
+ subject
+
+ expect(test_cases).to be_empty
+ expect(test_suite.total_count).to eq(0)
+ expect(test_suite.success_count).to eq(0)
+ expect(test_suite.error_count).to eq(0)
+ end
+
+ it 'returns a failure status' do
+ subject
+
+ expect(test_suite.total_status).to eq(Gitlab::Ci::Reports::TestCase::STATUS_ERROR)
+ end
+ end
+
+ context 'when data is not XML' do
+ let(:junit) { double(:random_trash) }
+
+ it 'attaches an error to the TestSuite object' do
+ expect { subject }.not_to raise_error
+ expect(test_suite.suite_error).to eq('JUnit data parsing failed: no implicit conversion of RSpec::Mocks::Double into String')
+ end
+
+ it 'returns 0 tests cases' do
+ subject
+
+ expect(test_cases).to be_empty
+ expect(test_suite.total_count).to eq(0)
+ expect(test_suite.success_count).to eq(0)
+ expect(test_suite.error_count).to eq(0)
+ end
+
+ it 'returns a failure status' do
+ subject
+
+ expect(test_suite.total_status).to eq(Gitlab::Ci::Reports::TestCase::STATUS_ERROR)
end
end
diff --git a/spec/lib/gitlab/ci/parsers_spec.rb b/spec/lib/gitlab/ci/parsers_spec.rb
index 9d6896b3cb4..0a266e7a206 100644
--- a/spec/lib/gitlab/ci/parsers_spec.rb
+++ b/spec/lib/gitlab/ci/parsers_spec.rb
@@ -22,6 +22,22 @@ describe Gitlab::Ci::Parsers do
end
end
+ context 'when file_type is accessibility' do
+ let(:file_type) { 'accessibility' }
+
+ it 'fabricates the class' do
+ is_expected.to be_a(described_class::Accessibility::Pa11y)
+ end
+ end
+
+ context 'when file_type is terraform' do
+ let(:file_type) { 'terraform' }
+
+ it 'fabricates the class' do
+ is_expected.to be_a(described_class::Terraform::Tfplan)
+ end
+ end
+
context 'when file_type does not exist' do
let(:file_type) { 'undefined' }
diff --git a/spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb
index 9033b71b19f..f82e49f9323 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb
@@ -5,11 +5,13 @@ require 'spec_helper'
describe Gitlab::Ci::Pipeline::Chain::Sequence do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
+
let(:pipeline) { build_stubbed(:ci_pipeline) }
let(:command) { Gitlab::Ci::Pipeline::Chain::Command.new }
let(:first_step) { spy('first step') }
let(:second_step) { spy('second step') }
let(:sequence) { [first_step, second_step] }
+ let(:histogram) { spy('prometheus metric') }
subject do
described_class.new(pipeline, command, sequence)
@@ -52,5 +54,13 @@ describe Gitlab::Ci::Pipeline::Chain::Sequence do
it 'returns a pipeline object' do
expect(subject.build!).to eq pipeline
end
+
+ it 'adds sequence duration to duration histogram' do
+ allow(command).to receive(:duration_histogram).and_return(histogram)
+
+ subject.build!
+
+ expect(histogram).to have_received(:observe)
+ end
end
end
diff --git a/spec/lib/gitlab/ci/reports/accessibility_reports_comparer_spec.rb b/spec/lib/gitlab/ci/reports/accessibility_reports_comparer_spec.rb
new file mode 100644
index 00000000000..31a330f46b1
--- /dev/null
+++ b/spec/lib/gitlab/ci/reports/accessibility_reports_comparer_spec.rb
@@ -0,0 +1,270 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Ci::Reports::AccessibilityReportsComparer do
+ let(:comparer) { described_class.new(base_reports, head_reports) }
+ let(:base_reports) { Gitlab::Ci::Reports::AccessibilityReports.new }
+ let(:head_reports) { Gitlab::Ci::Reports::AccessibilityReports.new }
+ let(:url) { "https://gitlab.com" }
+ let(:single_error) do
+ [
+ {
+ "code" => "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent",
+ "type" => "error",
+ "typeCode" => 1,
+ "message" => "Anchor element found with a valid href attribute, but no link content has been supplied.",
+ "context" => %{<a href="/" class="navbar-brand animated"><svg height="36" viewBox="0 0 1...</a>},
+ "selector" => "#main-nav > div:nth-child(1) > a",
+ "runner" => "htmlcs",
+ "runnerExtras" => {}
+ }
+ ]
+ end
+ let(:different_error) do
+ [
+ {
+ "code" => "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
+ "type" => "error",
+ "typeCode" => 1,
+ "message" => "This element has insufficient contrast at this conformance level.",
+ "context" => %{<a href="/stages-devops-lifecycle/" class="main-nav-link">Product</a>},
+ "selector" => "#main-nav > div:nth-child(2) > ul > li:nth-child(1) > a",
+ "runner" => "htmlcs",
+ "runnerExtras" => {}
+ }
+ ]
+ end
+
+ describe '#status' do
+ subject { comparer.status }
+
+ context 'when head report has an error' do
+ before do
+ head_reports.add_url(url, single_error)
+ end
+
+ it 'returns status failed' do
+ expect(subject).to eq(described_class::STATUS_FAILED)
+ end
+ end
+
+ context 'when head reports does not have errors' do
+ before do
+ head_reports.add_url(url, [])
+ end
+
+ it 'returns status success' do
+ expect(subject).to eq(described_class::STATUS_SUCCESS)
+ end
+ end
+ end
+
+ describe '#errors_count' do
+ subject { comparer.errors_count }
+
+ context 'when head report has an error' do
+ before do
+ head_reports.add_url(url, single_error)
+ end
+
+ it 'returns the number of new errors' do
+ expect(subject).to eq(1)
+ end
+ end
+
+ context 'when head reports does not have an error' do
+ before do
+ head_reports.add_url(url, [])
+ end
+
+ it 'returns the number new errors' do
+ expect(subject).to eq(0)
+ end
+ end
+ end
+
+ describe '#resolved_count' do
+ subject { comparer.resolved_count }
+
+ context 'when base reports has an error and head has a different error' do
+ before do
+ base_reports.add_url(url, single_error)
+ head_reports.add_url(url, different_error)
+ end
+
+ it 'returns the resolved count' do
+ expect(subject).to eq(1)
+ end
+ end
+
+ context 'when base reports has errors head has no errors' do
+ before do
+ base_reports.add_url(url, single_error)
+ head_reports.add_url(url, [])
+ end
+
+ it 'returns the resolved count' do
+ expect(subject).to eq(1)
+ end
+ end
+
+ context 'when base reports has errors and head has the same error' do
+ before do
+ base_reports.add_url(url, single_error)
+ head_reports.add_url(url, single_error)
+ end
+
+ it 'returns zero' do
+ expect(subject).to eq(0)
+ end
+ end
+
+ context 'when base reports does not have errors and head has errors' do
+ before do
+ head_reports.add_url(url, single_error)
+ end
+
+ it 'returns the number of resolved errors' do
+ expect(subject).to eq(0)
+ end
+ end
+ end
+
+ describe '#total_count' do
+ subject { comparer.total_count }
+
+ context 'when base reports has an error' do
+ before do
+ base_reports.add_url(url, single_error)
+ end
+
+ it 'returns the error count' do
+ expect(subject).to eq(1)
+ end
+ end
+
+ context 'when head report has an error' do
+ before do
+ head_reports.add_url(url, single_error)
+ end
+
+ it 'returns the error count' do
+ expect(subject).to eq(1)
+ end
+ end
+
+ context 'when base report has errors and head report has errors' do
+ before do
+ base_reports.add_url(url, single_error)
+ head_reports.add_url(url, different_error)
+ end
+
+ it 'returns the error count' do
+ expect(subject).to eq(2)
+ end
+ end
+ end
+
+ describe '#existing_errors' do
+ subject { comparer.existing_errors }
+
+ context 'when base report has errors and head has a different error' do
+ before do
+ base_reports.add_url(url, single_error)
+ head_reports.add_url(url, different_error)
+ end
+
+ it 'returns the existing errors' do
+ expect(subject.size).to eq(1)
+ expect(subject.first["code"]).to eq("WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent")
+ end
+ end
+
+ context 'when base report does not have errors and head has errors' do
+ before do
+ base_reports.add_url(url, [])
+ head_reports.add_url(url, single_error)
+ end
+
+ it 'returns an empty array' do
+ expect(subject).to be_empty
+ end
+ end
+ end
+
+ describe '#new_errors' do
+ subject { comparer.new_errors }
+
+ context 'when base reports has errors and head has more errors' do
+ before do
+ base_reports.add_url(url, single_error)
+ head_reports.add_url(url, single_error + different_error)
+ end
+
+ it 'returns new errors between base and head reports' do
+ expect(subject.size).to eq(1)
+ expect(subject.first["code"]).to eq("WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail")
+ end
+ end
+
+ context 'when base reports has an error and head has no errors' do
+ before do
+ base_reports.add_url(url, single_error)
+ head_reports.add_url(url, [])
+ end
+
+ it 'returns an empty array' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'when base reports does not have errors and head has errors' do
+ before do
+ head_reports.add_url(url, single_error)
+ end
+
+ it 'returns the new error' do
+ expect(subject.size).to eq(1)
+ expect(subject.first["code"]).to eq("WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent")
+ end
+ end
+ end
+
+ describe '#resolved_errors' do
+ subject { comparer.resolved_errors }
+
+ context 'when base report has errors and head has more errors' do
+ before do
+ base_reports.add_url(url, single_error)
+ head_reports.add_url(url, single_error + different_error)
+ end
+
+ it 'returns an empty array' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'when base reports has errors and head has a different error' do
+ before do
+ base_reports.add_url(url, single_error)
+ head_reports.add_url(url, different_error)
+ end
+
+ it 'returns the resolved errors' do
+ expect(subject.size).to eq(1)
+ expect(subject.first["code"]).to eq("WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent")
+ end
+ end
+
+ context 'when base reports does not have errors and head has errors' do
+ before do
+ head_reports.add_url(url, single_error)
+ end
+
+ it 'returns an empty array' do
+ expect(subject).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/reports/accessibility_reports_spec.rb b/spec/lib/gitlab/ci/reports/accessibility_reports_spec.rb
new file mode 100644
index 00000000000..0dc13b464b1
--- /dev/null
+++ b/spec/lib/gitlab/ci/reports/accessibility_reports_spec.rb
@@ -0,0 +1,232 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Ci::Reports::AccessibilityReports do
+ let(:accessibility_report) { described_class.new }
+ let(:url) { 'https://gitlab.com' }
+ let(:data) do
+ [
+ {
+ "code": "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent",
+ "type": "error",
+ "typeCode": 1,
+ "message": "Anchor element found with a valid href attribute, but no link content has been supplied.",
+ "context": %{<a href="/customers/worldline"><svg viewBox="0 0 509 89" xmln...</a>},
+ "selector": "html > body > div:nth-child(9) > div:nth-child(2) > a:nth-child(17)",
+ "runner": "htmlcs",
+ "runnerExtras": {}
+ },
+ {
+ "code": "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent",
+ "type": "error",
+ "typeCode": 1,
+ "message": "Anchor element found with a valid href attribute, but no link content has been supplied.",
+ "context": %{<a href="/customers/equinix"><svg xmlns="http://www.w3.org/...</a>},
+ "selector": "html > body > div:nth-child(9) > div:nth-child(2) > a:nth-child(18)",
+ "runner": "htmlcs",
+ "runnerExtras": {}
+ }
+ ]
+ end
+
+ describe '#scans_count' do
+ subject { accessibility_report.scans_count }
+
+ context 'when data has errors' do
+ let(:different_url) { 'https://about.gitlab.com' }
+
+ before do
+ accessibility_report.add_url(url, data)
+ accessibility_report.add_url(different_url, data)
+ end
+
+ it 'returns the scans_count' do
+ expect(subject).to eq(2)
+ end
+ end
+
+ context 'when data has no errors' do
+ before do
+ accessibility_report.add_url(url, [])
+ end
+
+ it 'returns the scans_count' do
+ expect(subject).to eq(1)
+ end
+ end
+
+ context 'when data has no url' do
+ before do
+ accessibility_report.add_url("", [])
+ end
+
+ it 'returns the scans_count' do
+ expect(subject).to eq(0)
+ end
+ end
+ end
+
+ describe '#passes_count' do
+ subject { accessibility_report.passes_count }
+
+ context 'when data has errors' do
+ before do
+ accessibility_report.add_url(url, data)
+ end
+
+ it 'returns the passes_count' do
+ expect(subject).to eq(0)
+ end
+ end
+
+ context 'when data has no errors' do
+ before do
+ accessibility_report.add_url(url, [])
+ end
+
+ it 'returns the passes_count' do
+ expect(subject).to eq(1)
+ end
+ end
+
+ context 'when data has no url' do
+ before do
+ accessibility_report.add_url("", [])
+ end
+
+ it 'returns the scans_count' do
+ expect(subject).to eq(0)
+ end
+ end
+ end
+
+ describe '#errors_count' do
+ subject { accessibility_report.errors_count }
+
+ context 'when data has errors' do
+ let(:different_url) { 'https://about.gitlab.com' }
+
+ before do
+ accessibility_report.add_url(url, data)
+ accessibility_report.add_url(different_url, data)
+ end
+
+ it 'returns the errors_count' do
+ expect(subject).to eq(4)
+ end
+ end
+
+ context 'when data has no errors' do
+ before do
+ accessibility_report.add_url(url, [])
+ end
+
+ it 'returns the errors_count' do
+ expect(subject).to eq(0)
+ end
+ end
+
+ context 'when data has no url' do
+ before do
+ accessibility_report.add_url("", [])
+ end
+
+ it 'returns the errors_count' do
+ expect(subject).to eq(0)
+ end
+ end
+ end
+
+ describe '#add_url' do
+ subject { accessibility_report.add_url(url, data) }
+
+ context 'when data has errors' do
+ it 'adds urls and data to accessibility report' do
+ expect { subject }.not_to raise_error
+
+ expect(accessibility_report.urls.keys).to eq([url])
+ expect(accessibility_report.urls.values.flatten.size).to eq(2)
+ end
+ end
+
+ context 'when data does not have errors' do
+ let(:data) { [] }
+
+ it 'adds data to accessibility report' do
+ expect { subject }.not_to raise_error
+
+ expect(accessibility_report.urls.keys).to eq([url])
+ expect(accessibility_report.urls.values.flatten.size).to eq(0)
+ end
+ end
+
+ context 'when url does not exist' do
+ let(:url) { '' }
+ let(:data) { [{ message: "Protocol error (Page.navigate): Cannot navigate to invalid URL" }] }
+
+ it 'sets error_message and decreases total' do
+ expect { subject }.not_to raise_error
+
+ expect(accessibility_report.scans_count).to eq(0)
+ expect(accessibility_report.error_message).to eq('Empty URL detected in gl-accessibility.json')
+ end
+ end
+ end
+
+ describe '#set_error_message' do
+ let(:set_accessibility_error) { accessibility_report.set_error_message('error') }
+
+ context 'when error is nil' do
+ it 'returns the error' do
+ expect(set_accessibility_error).to eq('error')
+ end
+
+ it 'sets the error' do
+ set_accessibility_error
+
+ expect(accessibility_report.error_message).to eq('error')
+ end
+ end
+
+ context 'when a error has already been set' do
+ before do
+ accessibility_report.set_error_message('old error')
+ end
+
+ it 'overwrites the existing message' do
+ expect { set_accessibility_error }.to change(accessibility_report, :error_message).from('old error').to('error')
+ end
+ end
+ end
+
+ describe '#all_errors' do
+ subject { accessibility_report.all_errors }
+
+ context 'when data has errors' do
+ before do
+ accessibility_report.add_url(url, data)
+ end
+
+ it 'returns all errors' do
+ expect(subject.size).to eq(2)
+ end
+ end
+
+ context 'when data has no errors' do
+ before do
+ accessibility_report.add_url(url, [])
+ end
+
+ it 'returns an empty array' do
+ expect(subject).to eq([])
+ end
+ end
+
+ context 'when accessibility report has no data' do
+ it 'returns an empty array' do
+ expect(subject).to eq([])
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/reports/terraform_reports_spec.rb b/spec/lib/gitlab/ci/reports/terraform_reports_spec.rb
new file mode 100644
index 00000000000..061029299ac
--- /dev/null
+++ b/spec/lib/gitlab/ci/reports/terraform_reports_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Ci::Reports::TerraformReports do
+ it 'initializes plans with and empty hash' do
+ expect(subject.plans).to eq({})
+ end
+
+ describe '#add_plan' do
+ context 'when providing two unique plans' do
+ it 'returns two plans' do
+ subject.add_plan('a/tfplan.json', { 'create' => 0, 'update' => 1, 'delete' => 0 })
+ subject.add_plan('b/tfplan.json', { 'create' => 0, 'update' => 1, 'delete' => 0 })
+
+ expect(subject.plans).to eq({
+ 'a/tfplan.json' => { 'create' => 0, 'update' => 1, 'delete' => 0 },
+ 'b/tfplan.json' => { 'create' => 0, 'update' => 1, 'delete' => 0 }
+ })
+ end
+ end
+
+ context 'when providing the same plan twice' do
+ it 'returns the last added plan' do
+ subject.add_plan('tfplan.json', { 'create' => 0, 'update' => 0, 'delete' => 0 })
+ subject.add_plan('tfplan.json', { 'create' => 0, 'update' => 1, 'delete' => 0 })
+
+ expect(subject.plans).to eq({
+ 'tfplan.json' => { 'create' => 0, 'update' => 1, 'delete' => 0 }
+ })
+ end
+ 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 c0652288cca..b5883867983 100644
--- a/spec/lib/gitlab/ci/reports/test_case_spec.rb
+++ b/spec/lib/gitlab/ci/reports/test_case_spec.rb
@@ -62,7 +62,7 @@ describe Gitlab::Ci::Reports::TestCase do
end
context 'when attachment is present' do
- let(:attachment_test_case) { build(:test_case, :with_attachment) }
+ let(:attachment_test_case) { build(:test_case, :failed_with_attachment) }
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_reports_spec.rb b/spec/lib/gitlab/ci/reports/test_reports_spec.rb
index 638acde69eb..e51728496e1 100644
--- a/spec/lib/gitlab/ci/reports/test_reports_spec.rb
+++ b/spec/lib/gitlab/ci/reports/test_reports_spec.rb
@@ -127,7 +127,7 @@ describe Gitlab::Ci::Reports::TestReports do
context 'when test suites contain an attachment' do
let(:test_case_succes) { build(:test_case) }
- let(:test_case_with_attachment) { build(:test_case, :with_attachment) }
+ let(:test_case_with_attachment) { build(:test_case, :failed_with_attachment) }
before do
test_reports.get_suite('rspec').add_test_case(test_case_succes)
@@ -141,6 +141,29 @@ describe Gitlab::Ci::Reports::TestReports do
end
end
+ describe '#suite_errors' do
+ subject { test_reports.suite_errors }
+
+ context 'when a suite has normal spec errors or failures' do
+ before do
+ test_reports.get_suite('junit').add_test_case(create_test_case_java_success)
+ test_reports.get_suite('junit').add_test_case(create_test_case_java_failed)
+ test_reports.get_suite('junit').add_test_case(create_test_case_java_error)
+ end
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when there is an error test case' do
+ before do
+ test_reports.get_suite('rspec').add_test_case(create_test_case_rspec_success)
+ test_reports.get_suite('junit').set_suite_error('Existential parsing error')
+ end
+
+ it { is_expected.to eq({ 'junit' => 'Existential parsing error' }) }
+ end
+ end
+
Gitlab::Ci::Reports::TestCase::STATUS_TYPES.each do |status_type|
describe "##{status_type}_count" do
subject { test_reports.public_send("#{status_type}_count") }
diff --git a/spec/lib/gitlab/ci/reports/test_suite_spec.rb b/spec/lib/gitlab/ci/reports/test_suite_spec.rb
index 9d9774afc82..e0b2593353a 100644
--- a/spec/lib/gitlab/ci/reports/test_suite_spec.rb
+++ b/spec/lib/gitlab/ci/reports/test_suite_spec.rb
@@ -101,7 +101,7 @@ describe Gitlab::Ci::Reports::TestSuite do
end
context 'when test cases contain an attachment' do
- let(:test_case_with_attachment) { build(:test_case, :with_attachment)}
+ let(:test_case_with_attachment) { build(:test_case, :failed_with_attachment)}
before do
test_suite.add_test_case(test_case_with_attachment)
@@ -114,6 +114,31 @@ describe Gitlab::Ci::Reports::TestSuite do
end
end
+ describe '#set_suite_error' do
+ let(:set_suite_error) { test_suite.set_suite_error('message') }
+
+ context 'when @suite_error is nil' do
+ it 'returns message' do
+ expect(set_suite_error).to eq('message')
+ end
+
+ it 'sets the new message' do
+ set_suite_error
+ expect(test_suite.suite_error).to eq('message')
+ end
+ end
+
+ context 'when a suite_error has already been set' do
+ before do
+ test_suite.set_suite_error('old message')
+ end
+
+ it 'overwrites the existing message' do
+ expect { set_suite_error }.to change(test_suite, :suite_error).from('old message').to('message')
+ 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/templates/Jobs/browser_performance_testing_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Jobs/browser_performance_testing_gitlab_ci_yaml_spec.rb
new file mode 100644
index 00000000000..54c3500b0a0
--- /dev/null
+++ b/spec/lib/gitlab/ci/templates/Jobs/browser_performance_testing_gitlab_ci_yaml_spec.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Jobs/Browser-Performance-Testing.gitlab-ci.yml' do
+ subject(:template) do
+ <<~YAML
+ stages:
+ - test
+ - performance
+
+ include:
+ - template: 'Jobs/Browser-Performance-Testing.gitlab-ci.yml'
+
+ placeholder:
+ script:
+ - keep pipeline validator happy by having a job when stages are intentionally empty
+ YAML
+ end
+
+ describe 'the created pipeline' do
+ let(:user) { create(:admin) }
+ let(:project) do
+ create(:project, :repository, variables: [
+ build(:ci_variable, key: 'CI_KUBERNETES_ACTIVE', value: 'true')
+ ])
+ end
+
+ let(:default_branch) { 'master' }
+ let(:pipeline_ref) { default_branch }
+ let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_ref) }
+ let(:pipeline) { service.execute!(:push) }
+ let(:build_names) { pipeline.builds.pluck(:name) }
+
+ before do
+ stub_ci_pipeline_yaml_file(template)
+
+ allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true)
+ allow(project).to receive(:default_branch).and_return(default_branch)
+ end
+
+ it 'has no errors' do
+ expect(pipeline.errors).to be_empty
+ end
+
+ shared_examples_for 'performance job on tag or branch' do
+ it 'by default' do
+ expect(build_names).to include('performance')
+ end
+
+ it 'when PERFORMANCE_DISABLED' do
+ create(:ci_variable, project: project, key: 'PERFORMANCE_DISABLED', value: '1')
+
+ expect(build_names).not_to include('performance')
+ end
+ end
+
+ context 'on master' do
+ it_behaves_like 'performance job on tag or branch'
+ end
+
+ context 'on another branch' do
+ let(:pipeline_ref) { 'feature' }
+
+ it_behaves_like 'performance job on tag or branch'
+ end
+
+ context 'on tag' do
+ let(:pipeline_ref) { 'v1.0.0' }
+
+ it_behaves_like 'performance job on tag or branch'
+ end
+
+ context 'on merge request' do
+ let(:service) { MergeRequests::CreatePipelineService.new(project, user) }
+ let(:merge_request) { create(:merge_request, :simple, source_project: project) }
+ let(:pipeline) { service.execute(merge_request) }
+
+ it 'has no jobs' do
+ expect(pipeline).to be_merge_request_event
+ expect(build_names).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/templates/Jobs/build_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Jobs/build_gitlab_ci_yaml_spec.rb
new file mode 100644
index 00000000000..b2a9e3f5cf4
--- /dev/null
+++ b/spec/lib/gitlab/ci/templates/Jobs/build_gitlab_ci_yaml_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Jobs/Build.gitlab-ci.yml' do
+ subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Jobs/Build') }
+
+ describe 'the created pipeline' do
+ let_it_be(:user) { create(:admin) }
+ let_it_be(:project) { create(:project, :repository) }
+
+ let(:default_branch) { 'master' }
+ let(:pipeline_ref) { default_branch }
+ let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_ref) }
+ let(:pipeline) { service.execute!(:push) }
+ let(:build_names) { pipeline.builds.pluck(:name) }
+
+ before do
+ stub_ci_pipeline_yaml_file(template.content)
+ allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true)
+ allow(project).to receive(:default_branch).and_return(default_branch)
+ end
+
+ context 'on master' do
+ it 'creates the build job' do
+ expect(build_names).to contain_exactly('build')
+ end
+ end
+
+ context 'on another branch' do
+ let(:pipeline_ref) { 'feature' }
+
+ it 'creates the build job' do
+ expect(build_names).to contain_exactly('build')
+ end
+ end
+
+ context 'on tag' do
+ let(:pipeline_ref) { 'v1.0.0' }
+
+ it 'creates the build job' do
+ expect(pipeline).to be_tag
+ expect(build_names).to contain_exactly('build')
+ end
+ end
+
+ context 'on merge request' do
+ let(:service) { MergeRequests::CreatePipelineService.new(project, user) }
+ let(:merge_request) { create(:merge_request, :simple, source_project: project) }
+ let(:pipeline) { service.execute(merge_request) }
+
+ it 'has no jobs' do
+ expect(pipeline).to be_merge_request_event
+ expect(build_names).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/templates/Jobs/code_quality_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Jobs/code_quality_gitlab_ci_yaml_spec.rb
new file mode 100644
index 00000000000..9c5b2fd5099
--- /dev/null
+++ b/spec/lib/gitlab/ci/templates/Jobs/code_quality_gitlab_ci_yaml_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Jobs/Code-Quality.gitlab-ci.yml' do
+ subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Jobs/Code-Quality') }
+
+ describe 'the created pipeline' do
+ let_it_be(:user) { create(:admin) }
+ let_it_be(:project) { create(:project, :repository) }
+
+ let(:default_branch) { 'master' }
+ let(:pipeline_ref) { default_branch }
+ let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_ref) }
+ let(:pipeline) { service.execute!(:push) }
+ let(:build_names) { pipeline.builds.pluck(:name) }
+
+ before do
+ stub_ci_pipeline_yaml_file(template.content)
+ allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true)
+ allow(project).to receive(:default_branch).and_return(default_branch)
+ end
+
+ context 'on master' do
+ it 'creates the code_quality job' do
+ expect(build_names).to contain_exactly('code_quality')
+ end
+ end
+
+ context 'on another branch' do
+ let(:pipeline_ref) { 'feature' }
+
+ it 'creates the code_quality job' do
+ expect(build_names).to contain_exactly('code_quality')
+ end
+ end
+
+ context 'on tag' do
+ let(:pipeline_ref) { 'v1.0.0' }
+
+ it 'creates the code_quality job' do
+ expect(pipeline).to be_tag
+ expect(build_names).to contain_exactly('code_quality')
+ end
+ end
+
+ context 'on merge request' do
+ let(:service) { MergeRequests::CreatePipelineService.new(project, user) }
+ let(:merge_request) { create(:merge_request, :simple, source_project: project) }
+ let(:pipeline) { service.execute(merge_request) }
+
+ it 'has no jobs' do
+ expect(pipeline).to be_merge_request_event
+ expect(build_names).to be_empty
+ end
+ end
+
+ context 'CODE_QUALITY_DISABLED is set' do
+ before do
+ create(:ci_variable, key: 'CODE_QUALITY_DISABLED', value: 'true', project: project)
+ end
+
+ context 'on master' do
+ it 'has no jobs' do
+ expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError)
+ end
+ end
+
+ context 'on another branch' do
+ let(:pipeline_ref) { 'feature' }
+
+ it 'has no jobs' do
+ expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError)
+ end
+ end
+
+ context 'on tag' do
+ let(:pipeline_ref) { 'v1.0.0' }
+
+ it 'has no jobs' do
+ expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/templates/Jobs/deploy_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Jobs/deploy_gitlab_ci_yaml_spec.rb
new file mode 100644
index 00000000000..a6ae23c85d3
--- /dev/null
+++ b/spec/lib/gitlab/ci/templates/Jobs/deploy_gitlab_ci_yaml_spec.rb
@@ -0,0 +1,222 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Jobs/Deploy.gitlab-ci.yml' do
+ subject(:template) do
+ <<~YAML
+ stages:
+ - test
+ - review
+ - staging
+ - canary
+ - production
+ - incremental rollout 10%
+ - incremental rollout 25%
+ - incremental rollout 50%
+ - incremental rollout 100%
+ - cleanup
+
+ include:
+ - template: Jobs/Deploy.gitlab-ci.yml
+
+ placeholder:
+ script:
+ - echo "Ensure at least one job to keep pipeline validator happy"
+ YAML
+ end
+
+ describe 'the created pipeline' do
+ let(:user) { create(:admin) }
+ let(:project) { create(:project, :repository) }
+
+ let(:default_branch) { 'master' }
+ let(:pipeline_ref) { default_branch }
+ let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_ref) }
+ let(:pipeline) { service.execute!(:push) }
+ let(:build_names) { pipeline.builds.pluck(:name) }
+
+ before do
+ stub_ci_pipeline_yaml_file(template)
+
+ allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true)
+ allow(project).to receive(:default_branch).and_return(default_branch)
+ end
+
+ context 'with no cluster' do
+ it 'does not create any kubernetes deployment jobs' do
+ expect(build_names).to eq %w(placeholder)
+ end
+ end
+
+ context 'with only a disabled cluster' do
+ let!(:cluster) { create(:cluster, :project, :provided_by_gcp, enabled: false, projects: [project]) }
+
+ it 'does not create any kubernetes deployment jobs' do
+ expect(build_names).to eq %w(placeholder)
+ end
+ end
+
+ context 'with an active cluster' do
+ let!(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
+
+ context 'on master' do
+ it 'by default' do
+ expect(build_names).to include('production')
+ expect(build_names).not_to include('review')
+ end
+
+ it 'when CANARY_ENABLED' do
+ create(:ci_variable, project: project, key: 'CANARY_ENABLED', value: 'true')
+
+ expect(build_names).to include('production_manual')
+ expect(build_names).to include('canary')
+ expect(build_names).not_to include('production')
+ end
+
+ it 'when STAGING_ENABLED' do
+ create(:ci_variable, project: project, key: 'STAGING_ENABLED', value: 'true')
+
+ expect(build_names).to include('production_manual')
+ expect(build_names).to include('staging')
+ expect(build_names).not_to include('production')
+ end
+
+ it 'when INCREMENTAL_ROLLOUT_MODE == timed' do
+ create(:ci_variable, project: project, key: 'INCREMENTAL_ROLLOUT_ENABLED', value: 'true')
+ create(:ci_variable, project: project, key: 'INCREMENTAL_ROLLOUT_MODE', value: 'timed')
+
+ expect(build_names).not_to include('production_manual')
+ expect(build_names).not_to include('production')
+ expect(build_names).not_to include(
+ 'rollout 10%',
+ 'rollout 25%',
+ 'rollout 50%',
+ 'rollout 100%'
+ )
+ expect(build_names).to include(
+ 'timed rollout 10%',
+ 'timed rollout 25%',
+ 'timed rollout 50%',
+ 'timed rollout 100%'
+ )
+ end
+
+ it 'when INCREMENTAL_ROLLOUT_ENABLED' do
+ create(:ci_variable, project: project, key: 'INCREMENTAL_ROLLOUT_ENABLED', value: 'true')
+
+ expect(build_names).not_to include('production_manual')
+ expect(build_names).not_to include('production')
+ expect(build_names).not_to include(
+ 'timed rollout 10%',
+ 'timed rollout 25%',
+ 'timed rollout 50%',
+ 'timed rollout 100%'
+ )
+ expect(build_names).to include(
+ 'rollout 10%',
+ 'rollout 25%',
+ 'rollout 50%',
+ 'rollout 100%'
+ )
+ end
+
+ it 'when INCREMENTAL_ROLLOUT_MODE == manual' do
+ create(:ci_variable, project: project, key: 'INCREMENTAL_ROLLOUT_MODE', value: 'manual')
+
+ expect(build_names).not_to include('production_manual')
+ expect(build_names).not_to include('production')
+ expect(build_names).not_to include(
+ 'timed rollout 10%',
+ 'timed rollout 25%',
+ 'timed rollout 50%',
+ 'timed rollout 100%'
+ )
+ expect(build_names).to include(
+ 'rollout 10%',
+ 'rollout 25%',
+ 'rollout 50%',
+ 'rollout 100%'
+ )
+ end
+ end
+
+ shared_examples_for 'review app deployment' do
+ it 'creates the review and stop_review jobs but no production jobs' do
+ expect(build_names).to include('review')
+ expect(build_names).to include('stop_review')
+ expect(build_names).not_to include('production')
+ expect(build_names).not_to include('production_manual')
+ expect(build_names).not_to include('staging')
+ expect(build_names).not_to include('canary')
+ expect(build_names).not_to include('timed rollout 10%')
+ expect(build_names).not_to include('timed rollout 25%')
+ expect(build_names).not_to include('timed rollout 50%')
+ expect(build_names).not_to include('timed rollout 100%')
+ expect(build_names).not_to include('rollout 10%')
+ expect(build_names).not_to include('rollout 25%')
+ expect(build_names).not_to include('rollout 50%')
+ expect(build_names).not_to include('rollout 100%')
+ end
+
+ it 'does not include review when REVIEW_DISABLED' do
+ create(:ci_variable, project: project, key: 'REVIEW_DISABLED', value: 'true')
+
+ expect(build_names).not_to include('review')
+ expect(build_names).not_to include('stop_review')
+ end
+ end
+
+ context 'on branch' do
+ let(:pipeline_ref) { 'feature' }
+
+ before do
+ allow_any_instance_of(Gitlab::Ci::Pipeline::Chain::Validate::Repository).to receive(:perform!).and_return(true)
+ end
+
+ it_behaves_like 'review app deployment'
+
+ context 'when INCREMENTAL_ROLLOUT_ENABLED' do
+ before do
+ create(:ci_variable, project: project, key: 'INCREMENTAL_ROLLOUT_ENABLED', value: 'true')
+ end
+
+ it_behaves_like 'review app deployment'
+ end
+
+ context 'when INCREMENTAL_ROLLOUT_MODE == "timed"' do
+ before do
+ create(:ci_variable, project: project, key: 'INCREMENTAL_ROLLOUT_MODE', value: 'timed')
+ end
+
+ it_behaves_like 'review app deployment'
+ end
+
+ context 'when INCREMENTAL_ROLLOUT_MODE == "manual"' do
+ before do
+ create(:ci_variable, project: project, key: 'INCREMENTAL_ROLLOUT_MODE', value: 'manual')
+ end
+
+ it_behaves_like 'review app deployment'
+ end
+ end
+
+ context 'on tag' do
+ let(:pipeline_ref) { 'v1.0.0' }
+
+ it_behaves_like 'review app deployment'
+ end
+
+ context 'on merge request' do
+ let(:service) { MergeRequests::CreatePipelineService.new(project, user) }
+ let(:merge_request) { create(:merge_request, :simple, source_project: project) }
+ let(:pipeline) { service.execute(merge_request) }
+
+ it 'has no jobs' do
+ expect(pipeline).to be_merge_request_event
+ expect(build_names).to be_empty
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/templates/Jobs/test_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Jobs/test_gitlab_ci_yaml_spec.rb
new file mode 100644
index 00000000000..2186bf038eb
--- /dev/null
+++ b/spec/lib/gitlab/ci/templates/Jobs/test_gitlab_ci_yaml_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Jobs/Test.gitlab-ci.yml' do
+ subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Jobs/Test') }
+
+ describe 'the created pipeline' do
+ let_it_be(:user) { create(:admin) }
+ let_it_be(:project) { create(:project, :repository) }
+
+ let(:default_branch) { 'master' }
+ let(:pipeline_ref) { default_branch }
+ let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_ref) }
+ let(:pipeline) { service.execute!(:push) }
+ let(:build_names) { pipeline.builds.pluck(:name) }
+
+ before do
+ stub_ci_pipeline_yaml_file(template.content)
+ allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true)
+ allow(project).to receive(:default_branch).and_return(default_branch)
+ end
+
+ context 'on master' do
+ it 'creates the test job' do
+ expect(build_names).to contain_exactly('test')
+ end
+ end
+
+ context 'on another branch' do
+ let(:pipeline_ref) { 'feature' }
+
+ it 'creates the test job' do
+ expect(build_names).to contain_exactly('test')
+ end
+ end
+
+ context 'on tag' do
+ let(:pipeline_ref) { 'v1.0.0' }
+
+ it 'creates the test job' do
+ expect(pipeline).to be_tag
+ expect(build_names).to contain_exactly('test')
+ end
+ end
+
+ context 'on merge request' do
+ let(:service) { MergeRequests::CreatePipelineService.new(project, user) }
+ let(:merge_request) { create(:merge_request, :simple, source_project: project) }
+ let(:pipeline) { service.execute(merge_request) }
+
+ it 'has no jobs' do
+ expect(pipeline).to be_merge_request_event
+ expect(build_names).to be_empty
+ end
+ end
+
+ context 'TEST_DISABLED is set' do
+ before do
+ create(:ci_variable, key: 'TEST_DISABLED', value: 'true', project: project)
+ end
+
+ context 'on master' do
+ it 'has no jobs' do
+ expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError)
+ end
+ end
+
+ context 'on another branch' do
+ let(:pipeline_ref) { 'feature' }
+
+ it 'has no jobs' do
+ expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError)
+ end
+ end
+
+ context 'on tag' do
+ let(:pipeline_ref) { 'v1.0.0' }
+
+ it 'has no jobs' do
+ expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb
index 0c5d172f17c..af6ec25b9d6 100644
--- a/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb
+++ b/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb
@@ -20,16 +20,8 @@ describe 'Auto-DevOps.gitlab-ci.yml' do
allow(project).to receive(:default_branch).and_return(default_branch)
end
- it 'creates a build and a test job' do
- expect(build_names).to include('build', 'test')
- end
-
- context 'when the project has no active cluster' do
- it 'only creates a build and a test stage' do
- expect(pipeline.stages_names).to eq(%w(build test))
- end
-
- it 'does not create any deployment-related builds' do
+ shared_examples 'no Kubernetes deployment job' do
+ it 'does not create any Kubernetes deployment-related builds' do
expect(build_names).not_to include('production')
expect(build_names).not_to include('production_manual')
expect(build_names).not_to include('staging')
@@ -39,13 +31,95 @@ describe 'Auto-DevOps.gitlab-ci.yml' do
end
end
- context 'when the project has an active cluster' do
- let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
+ it 'creates a build and a test job' do
+ expect(build_names).to include('build', 'test')
+ end
+
+ context 'when the project is set for deployment to AWS' do
+ let(:platform_value) { 'ECS' }
before do
- allow(cluster).to receive(:active?).and_return(true)
+ create(:ci_variable, project: project, key: 'AUTO_DEVOPS_PLATFORM_TARGET', value: platform_value)
+ end
+
+ shared_examples 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do |job_name|
+ context 'when AUTO_DEVOPS_PLATFORM_TARGET is nil' do
+ let(:platform_value) { nil }
+
+ it 'does not trigger the job' do
+ expect(build_names).not_to include(job_name)
+ end
+ end
+
+ context 'when AUTO_DEVOPS_PLATFORM_TARGET is empty' do
+ let(:platform_value) { '' }
+
+ it 'does not trigger the job' do
+ expect(build_names).not_to include(job_name)
+ end
+ end
+ end
+
+ it_behaves_like 'no Kubernetes deployment job'
+
+ it_behaves_like 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do
+ let(:job_name) { 'production_ecs' }
+ end
+
+ it 'creates an ECS deployment job for production only' do
+ expect(build_names).not_to include('review_ecs')
+ expect(build_names).to include('production_ecs')
end
+ context 'and we are not on the default branch' do
+ let(:platform_value) { 'ECS' }
+ let(:pipeline_branch) { 'patch-1' }
+
+ before do
+ project.repository.create_branch(pipeline_branch)
+ end
+
+ it_behaves_like 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do
+ let(:job_name) { 'review_ecs' }
+ end
+
+ it 'creates an ECS deployment job for review only' do
+ expect(build_names).to include('review_ecs')
+ expect(build_names).not_to include('production_ecs')
+ expect(build_names).not_to include('review')
+ expect(build_names).not_to include('production')
+ end
+ end
+
+ context 'and when the project has an active cluster' do
+ let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
+
+ before do
+ allow(cluster).to receive(:active?).and_return(true)
+ end
+
+ context 'on default branch' do
+ it 'triggers the deployment to Kubernetes, not to ECS' do
+ expect(build_names).not_to include('review')
+ expect(build_names).to include('production')
+ expect(build_names).not_to include('production_ecs')
+ expect(build_names).not_to include('review_ecs')
+ end
+ end
+ end
+ end
+
+ context 'when the project has no active cluster' do
+ it 'only creates a build and a test stage' do
+ expect(pipeline.stages_names).to eq(%w(build test))
+ end
+
+ it_behaves_like 'no Kubernetes deployment job'
+ end
+
+ context 'when the project has an active cluster' do
+ let!(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
+
describe 'deployment-related builds' do
context 'on default branch' do
it 'does not include rollout jobs besides production' do
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index 70c3c5ab339..c93bb901981 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -1364,6 +1364,24 @@ module Gitlab
expect { described_class.new(config) }.to raise_error(described_class::ValidationError)
end
+
+ it 'populates a build options with complete artifacts configuration' do
+ stub_feature_flags(ci_artifacts_exclude: true)
+
+ config = <<~YAML
+ test:
+ script: echo "Hello World"
+ artifacts:
+ paths:
+ - my/test
+ exclude:
+ - my/test/something
+ YAML
+
+ attributes = Gitlab::Ci::YamlProcessor.new(config).build_attributes('test')
+
+ expect(attributes.dig(*%i[options artifacts exclude])).to eq(%w[my/test/something])
+ end
end
describe "release" do
@@ -2264,14 +2282,14 @@ module Gitlab
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: stage parameter should be .pre, build, test, deploy, .post")
+ end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "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: stage parameter should be .pre, build, test, .post")
+ end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "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
diff --git a/spec/lib/gitlab/code_navigation_path_spec.rb b/spec/lib/gitlab/code_navigation_path_spec.rb
index cafe362c8c7..938a2f821fd 100644
--- a/spec/lib/gitlab/code_navigation_path_spec.rb
+++ b/spec/lib/gitlab/code_navigation_path_spec.rb
@@ -4,18 +4,29 @@ require 'spec_helper'
describe Gitlab::CodeNavigationPath do
context 'when there is an artifact with code navigation data' do
- let(:project) { create(:project, :repository) }
- let(:sha) { project.commit.id }
- let(:build_name) { Gitlab::CodeNavigationPath::CODE_NAVIGATION_JOB_NAME }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:sha) { project.repository.commits('master', limit: 5).last.id }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project, sha: sha) }
+ let_it_be(:job) { create(:ci_build, pipeline: pipeline) }
+ let_it_be(:artifact) { create(:ci_job_artifact, :lsif, job: job) }
+
+ let(:commit_sha) { sha }
let(:path) { 'lib/app.rb' }
- let!(:pipeline) { create(:ci_pipeline, project: project, sha: sha) }
- let!(:job) { create(:ci_build, pipeline: pipeline, name: build_name) }
- let!(:artifact) { create(:ci_job_artifact, :lsif, job: job) }
- subject { described_class.new(project, sha).full_json_path_for(path) }
+ subject { described_class.new(project, commit_sha).full_json_path_for(path) }
+
+ context 'when a pipeline exist for a sha' do
+ it 'returns path to a file in the artifact' do
+ expect(subject).to eq("/#{project.full_path}/-/jobs/#{job.id}/artifacts/raw/lsif/#{path}.json?file_type=lsif")
+ end
+ end
+
+ context 'when a pipeline exist for the latest commits' do
+ let(:commit_sha) { project.commit.id }
- it 'assigns code_navigation_build variable' do
- expect(subject).to eq("/#{project.full_path}/-/jobs/#{job.id}/artifacts/raw/lsif/#{path}.json")
+ it 'returns path to a file in the artifact' do
+ expect(subject).to eq("/#{project.full_path}/-/jobs/#{job.id}/artifacts/raw/lsif/#{path}.json?file_type=lsif")
+ end
end
context 'when code_navigation feature is disabled' do
@@ -23,7 +34,7 @@ describe Gitlab::CodeNavigationPath do
stub_feature_flags(code_navigation: false)
end
- it 'does not assign code_navigation_build variable' do
+ it 'returns nil' do
expect(subject).to be_nil
end
end
diff --git a/spec/lib/gitlab/config_checker/external_database_checker_spec.rb b/spec/lib/gitlab/config_checker/external_database_checker_spec.rb
new file mode 100644
index 00000000000..d86d132c237
--- /dev/null
+++ b/spec/lib/gitlab/config_checker/external_database_checker_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::ConfigChecker::ExternalDatabaseChecker do
+ describe '#check' do
+ subject { described_class.check }
+
+ context 'database version is not deprecated' do
+ before do
+ allow(described_class).to receive(:db_version_deprecated?).and_return(false)
+ end
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'database version is deprecated' do
+ before do
+ allow(described_class).to receive(:db_version_deprecated?).and_return(true)
+ end
+
+ let(:notice_deprecated_database) do
+ {
+ type: 'warning',
+ message: _('Note that PostgreSQL 11 will become the minimum required PostgreSQL version in GitLab 13.0 (May 2020). '\
+ 'PostgreSQL 9.6 and PostgreSQL 10 will no longer be supported in GitLab 13.0. '\
+ 'Please consider upgrading your PostgreSQL version (%{db_version}) soon.') % { db_version: Gitlab::Database.version.to_s }
+ }
+ end
+
+ it 'reports deprecated database notices' do
+ is_expected.to contain_exactly(notice_deprecated_database)
+ end
+ end
+ end
+
+ describe '#db_version_deprecated' do
+ subject { described_class.db_version_deprecated? }
+
+ context 'database version is not deprecated' do
+ before do
+ allow(Gitlab::Database).to receive(:version).and_return(11)
+ end
+
+ it { is_expected.to be false }
+ end
+
+ context 'database version is deprecated' do
+ before do
+ allow(Gitlab::Database).to receive(:version).and_return(10)
+ end
+
+ it { is_expected.to be true }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/group_stage_summary_spec.rb b/spec/lib/gitlab/cycle_analytics/group_stage_summary_spec.rb
deleted file mode 100644
index 2242895f8ea..00000000000
--- a/spec/lib/gitlab/cycle_analytics/group_stage_summary_spec.rb
+++ /dev/null
@@ -1,176 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-describe Gitlab::CycleAnalytics::GroupStageSummary do
- let(:group) { create(:group) }
- let(:project) { create(:project, :repository, namespace: group) }
- let(:project_2) { create(:project, :repository, namespace: group) }
- let(:from) { 1.day.ago }
- let(:user) { create(:user, :admin) }
-
- subject { described_class.new(group, options: { from: Time.now, current_user: user }).data }
-
- describe "#new_issues" do
- context 'with from date' do
- before do
- Timecop.freeze(5.days.ago) { create(:issue, project: project) }
- Timecop.freeze(5.days.ago) { create(:issue, project: project_2) }
- Timecop.freeze(5.days.from_now) { create(:issue, project: project) }
- Timecop.freeze(5.days.from_now) { create(:issue, project: project_2) }
- end
-
- it "finds the number of issues created after it" do
- expect(subject.first[:value]).to eq('2')
- end
-
- context 'with subgroups' do
- before do
- Timecop.freeze(5.days.from_now) { create(:issue, project: create(:project, namespace: create(:group, parent: group))) }
- end
-
- it "finds issues from them" do
- expect(subject.first[:value]).to eq('3')
- end
- end
-
- context 'with projects specified in options' do
- before do
- Timecop.freeze(5.days.from_now) { create(:issue, project: create(:project, namespace: group)) }
- end
-
- subject { described_class.new(group, options: { from: Time.now, current_user: user, projects: [project.id, project_2.id] }).data }
-
- it 'finds issues from those projects' do
- expect(subject.first[:value]).to eq('2')
- end
- end
-
- context 'when `from` and `to` parameters are provided' do
- subject { described_class.new(group, options: { from: 10.days.ago, to: Time.now, current_user: user }).data }
-
- it 'finds issues from 5 days ago' do
- expect(subject.first[:value]).to eq('2')
- end
- end
- end
-
- context 'with other projects' do
- before do
- Timecop.freeze(5.days.from_now) { create(:issue, project: create(:project, namespace: create(:group))) }
- Timecop.freeze(5.days.from_now) { create(:issue, project: project) }
- Timecop.freeze(5.days.from_now) { create(:issue, project: project_2) }
- end
-
- it "doesn't find issues from them" do
- expect(subject.first[:value]).to eq('2')
- end
- end
- end
-
- describe "#deploys" do
- context 'with from date' do
- before do
- Timecop.freeze(5.days.ago) { create(:deployment, :success, project: project) }
- Timecop.freeze(5.days.from_now) { create(:deployment, :success, project: project) }
- Timecop.freeze(5.days.ago) { create(:deployment, :success, project: project_2) }
- Timecop.freeze(5.days.from_now) { create(:deployment, :success, project: project_2) }
- end
-
- it "finds the number of deploys made created after it" do
- expect(subject.second[:value]).to eq('2')
- end
-
- context 'with subgroups' do
- before do
- Timecop.freeze(5.days.from_now) do
- create(:deployment, :success, project: create(:project, :repository, namespace: create(:group, parent: group)))
- end
- end
-
- it "finds deploys from them" do
- expect(subject.second[:value]).to eq('3')
- end
- end
-
- context 'with projects specified in options' do
- before do
- Timecop.freeze(5.days.from_now) do
- create(:deployment, :success, project: create(:project, :repository, namespace: group, name: 'not_applicable'))
- end
- end
-
- subject { described_class.new(group, options: { from: Time.now, current_user: user, projects: [project.id, project_2.id] }).data }
-
- it 'shows deploys from those projects' do
- expect(subject.second[:value]).to eq('2')
- end
- end
-
- context 'when `from` and `to` parameters are provided' do
- subject { described_class.new(group, options: { from: 10.days.ago, to: Time.now, current_user: user }).data }
-
- it 'finds deployments from 5 days ago' do
- expect(subject.second[:value]).to eq('2')
- end
- end
- end
-
- context 'with other projects' do
- before do
- Timecop.freeze(5.days.from_now) do
- create(:deployment, :success, project: create(:project, :repository, namespace: create(:group)))
- end
- end
-
- it "doesn't find deploys from them" do
- expect(subject.second[:value]).to eq('-')
- end
- end
- end
-
- describe '#deployment_frequency' do
- let(:from) { 6.days.ago }
- let(:to) { nil }
-
- subject do
- described_class.new(group, options: {
- from: from,
- to: to,
- current_user: user
- }).data.third
- end
-
- it 'includes the unit: `per day`' do
- expect(subject[:unit]).to eq(_('per day'))
- end
-
- before do
- Timecop.freeze(5.days.ago) do
- create(:deployment, :success, project: project)
- end
- end
-
- context 'when `to` is nil' do
- it 'includes range until now' do
- # 1 deployment over 7 days
- expect(subject[:value]).to eq('0.1')
- end
- end
-
- context 'when `to` is given' do
- let(:from) { 10.days.ago }
- let(:to) { 10.days.from_now }
-
- before do
- Timecop.freeze(5.days.from_now) do
- create(:deployment, :success, project: project)
- end
- end
-
- it 'returns deployment frequency within `from` and `to` range' do
- # 2 deployments over 20 days
- expect(subject[:value]).to eq('0.1')
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/cycle_analytics/summary/value_spec.rb b/spec/lib/gitlab/cycle_analytics/summary/value_spec.rb
new file mode 100644
index 00000000000..d9bdfa92a04
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/summary/value_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::CycleAnalytics::Summary::Value do
+ describe Gitlab::CycleAnalytics::Summary::Value::None do
+ it 'returns `-`' do
+ expect(described_class.new.to_s).to eq('-')
+ end
+ end
+
+ describe Gitlab::CycleAnalytics::Summary::Value::Numeric do
+ it 'returns the string representation of the number' do
+ expect(described_class.new(3.2).to_s).to eq('3.2')
+ end
+ end
+
+ describe Gitlab::CycleAnalytics::Summary::Value::PrettyNumeric do
+ describe '#to_s' do
+ it 'returns `-` when the number is 0' do
+ expect(described_class.new(0).to_s).to eq('-')
+ end
+
+ it 'returns `-` when the number is nil' do
+ expect(described_class.new(nil).to_s).to eq('-')
+ end
+
+ it 'returns the string representation of the number' do
+ expect(described_class.new(100).to_s).to eq('100')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/danger/changelog_spec.rb b/spec/lib/gitlab/danger/changelog_spec.rb
index ba23c3828de..8929374fb87 100644
--- a/spec/lib/gitlab/danger/changelog_spec.rb
+++ b/spec/lib/gitlab/danger/changelog_spec.rb
@@ -86,14 +86,6 @@ describe Gitlab::Danger::Changelog do
end
end
- describe '#presented_no_changelog_labels' do
- subject { changelog.presented_no_changelog_labels }
-
- it 'returns the labels formatted' do
- is_expected.to eq('~backstage, ~ci-build, ~meta')
- end
- end
-
describe '#ee_changelog?' do
subject { changelog.ee_changelog? }
diff --git a/spec/lib/gitlab/danger/helper_spec.rb b/spec/lib/gitlab/danger/helper_spec.rb
index d5d582d7d6c..c2c881fd589 100644
--- a/spec/lib/gitlab/danger/helper_spec.rb
+++ b/spec/lib/gitlab/danger/helper_spec.rb
@@ -363,4 +363,69 @@ describe Gitlab::Danger::Helper do
expect(helper).to be_security_mr
end
end
+
+ describe '#mr_has_label?' do
+ it 'returns false when `gitlab_helper` is unavailable' do
+ expect(helper).to receive(:gitlab_helper).and_return(nil)
+
+ expect(helper.mr_has_labels?('telemetry')).to be_falsey
+ end
+
+ context 'when mr has labels' do
+ before do
+ mr_labels = ['telemetry', 'telemetry::reviewed']
+ expect(fake_gitlab).to receive(:mr_labels).and_return(mr_labels)
+ end
+
+ it 'returns true with a matched label' do
+ expect(helper.mr_has_labels?('telemetry')).to be_truthy
+ end
+
+ it 'returns false with unmatched label' do
+ expect(helper.mr_has_labels?('database')).to be_falsey
+ end
+
+ it 'returns true with an array of labels' do
+ expect(helper.mr_has_labels?(['telemetry', 'telemetry::reviewed'])).to be_truthy
+ end
+
+ it 'returns true with multi arguments with matched labels' do
+ expect(helper.mr_has_labels?('telemetry', 'telemetry::reviewed')).to be_truthy
+ end
+
+ it 'returns false with multi arguments with unmatched labels' do
+ expect(helper.mr_has_labels?('telemetry', 'telemetry::non existing')).to be_falsey
+ end
+ end
+ end
+
+ describe '#labels_list' do
+ let(:labels) { ['telemetry', 'telemetry::reviewed'] }
+
+ it 'composes the labels string' do
+ expect(helper.labels_list(labels)).to eq('~"telemetry", ~"telemetry::reviewed"')
+ end
+
+ context 'when passing a separator' do
+ it 'composes the labels string with the given separator' do
+ expect(helper.labels_list(labels, sep: ' ')).to eq('~"telemetry" ~"telemetry::reviewed"')
+ end
+ end
+
+ it 'returns empty string for empty array' do
+ expect(helper.labels_list([])).to eq('')
+ end
+ end
+
+ describe '#prepare_labels_for_mr' do
+ it 'composes the labels string' do
+ mr_labels = ['telemetry', 'telemetry::reviewed']
+
+ expect(helper.prepare_labels_for_mr(mr_labels)).to eq('/label ~"telemetry" ~"telemetry::reviewed"')
+ end
+
+ it 'returns empty string for empty array' do
+ expect(helper.prepare_labels_for_mr([])).to eq('')
+ end
+ end
end
diff --git a/spec/lib/gitlab/danger/teammate_spec.rb b/spec/lib/gitlab/danger/teammate_spec.rb
index 570f4bd27cc..ea5aecbc597 100644
--- a/spec/lib/gitlab/danger/teammate_spec.rb
+++ b/spec/lib/gitlab/danger/teammate_spec.rb
@@ -163,6 +163,13 @@ describe Gitlab::Danger::Teammate do
{ message: 'OOO: massage' } | false
{ message: 'love it SOOO much' } | false
{ emoji: 'red_circle' } | false
+ { emoji: 'palm_tree' } | false
+ { emoji: 'beach' } | false
+ { emoji: 'beach_umbrella' } | false
+ { emoji: 'beach_with_umbrella' } | false
+ { emoji: nil } | true
+ { emoji: '' } | true
+ { emoji: 'dancer' } | true
end
with_them do
@@ -175,9 +182,9 @@ describe Gitlab::Danger::Teammate do
end
it 'returns true if request fails' do
- expect(Gitlab::Danger::RequestHelper).to receive(:http_get_json)
- .twice
- .and_raise(Gitlab::Danger::RequestHelper::HTTPError.new)
+ expect(Gitlab::Danger::RequestHelper)
+ .to receive(:http_get_json)
+ .and_raise(Gitlab::Danger::RequestHelper::HTTPError.new)
expect(subject.available?).to be true
end
diff --git a/spec/lib/gitlab/database/batch_count_spec.rb b/spec/lib/gitlab/database/batch_count_spec.rb
index 7be84b8f980..e7cb53f2dbd 100644
--- a/spec/lib/gitlab/database/batch_count_spec.rb
+++ b/spec/lib/gitlab/database/batch_count_spec.rb
@@ -35,6 +35,10 @@ describe Gitlab::Database::BatchCount do
expect(described_class.batch_count(model, "#{model.table_name}.id")).to eq(5)
end
+ it 'counts with Arel column' do
+ expect(described_class.batch_count(model, model.arel_table[:id])).to eq(5)
+ end
+
it 'counts table with batch_size 50K' do
expect(described_class.batch_count(model, batch_size: 50_000)).to eq(5)
end
@@ -98,6 +102,10 @@ describe Gitlab::Database::BatchCount do
expect(described_class.batch_distinct_count(model, "#{model.table_name}.#{column}")).to eq(2)
end
+ it 'counts with Arel column' do
+ expect(described_class.batch_distinct_count(model, model.arel_table[column])).to eq(2)
+ end
+
it 'counts with :column field with batch_size of 50K' do
expect(described_class.batch_distinct_count(model, column, batch_size: 50_000)).to eq(2)
end
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index 3a0148615b9..203d39be22b 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -217,9 +217,10 @@ describe Gitlab::Database::MigrationHelpers do
it 'appends ON DELETE SET NULL statement' do
expect(model).to receive(:with_lock_retries).and_call_original
expect(model).to receive(:disable_statement_timeout).and_call_original
+ expect(model).to receive(:statement_timeout_disabled?).and_return(false)
expect(model).to receive(:execute).with(/statement_timeout/)
expect(model).to receive(:execute).ordered.with(/VALIDATE CONSTRAINT/)
- expect(model).to receive(:execute).with(/RESET ALL/)
+ expect(model).to receive(:execute).ordered.with(/RESET ALL/)
expect(model).to receive(:execute).with(/ON DELETE SET NULL/)
@@ -233,9 +234,10 @@ describe Gitlab::Database::MigrationHelpers do
it 'appends ON DELETE CASCADE statement' do
expect(model).to receive(:with_lock_retries).and_call_original
expect(model).to receive(:disable_statement_timeout).and_call_original
+ expect(model).to receive(:statement_timeout_disabled?).and_return(false)
expect(model).to receive(:execute).with(/statement_timeout/)
expect(model).to receive(:execute).ordered.with(/VALIDATE CONSTRAINT/)
- expect(model).to receive(:execute).with(/RESET ALL/)
+ expect(model).to receive(:execute).ordered.with(/RESET ALL/)
expect(model).to receive(:execute).with(/ON DELETE CASCADE/)
@@ -249,9 +251,10 @@ describe Gitlab::Database::MigrationHelpers do
it 'appends no ON DELETE statement' do
expect(model).to receive(:with_lock_retries).and_call_original
expect(model).to receive(:disable_statement_timeout).and_call_original
+ expect(model).to receive(:statement_timeout_disabled?).and_return(false)
expect(model).to receive(:execute).with(/statement_timeout/)
expect(model).to receive(:execute).ordered.with(/VALIDATE CONSTRAINT/)
- expect(model).to receive(:execute).with(/RESET ALL/)
+ expect(model).to receive(:execute).ordered.with(/RESET ALL/)
expect(model).not_to receive(:execute).with(/ON DELETE/)
@@ -266,10 +269,11 @@ describe Gitlab::Database::MigrationHelpers do
it 'creates a concurrent foreign key and validates it' do
expect(model).to receive(:with_lock_retries).and_call_original
expect(model).to receive(:disable_statement_timeout).and_call_original
+ expect(model).to receive(:statement_timeout_disabled?).and_return(false)
expect(model).to receive(:execute).with(/statement_timeout/)
expect(model).to receive(:execute).ordered.with(/NOT VALID/)
expect(model).to receive(:execute).ordered.with(/VALIDATE CONSTRAINT/)
- expect(model).to receive(:execute).with(/RESET ALL/)
+ expect(model).to receive(:execute).ordered.with(/RESET ALL/)
model.add_concurrent_foreign_key(:projects, :users, column: :user_id)
end
@@ -293,10 +297,11 @@ describe Gitlab::Database::MigrationHelpers do
it 'creates a new foreign key' do
expect(model).to receive(:with_lock_retries).and_call_original
expect(model).to receive(:disable_statement_timeout).and_call_original
+ expect(model).to receive(:statement_timeout_disabled?).and_return(false)
expect(model).to receive(:execute).with(/statement_timeout/)
expect(model).to receive(:execute).ordered.with(/NOT VALID/)
expect(model).to receive(:execute).ordered.with(/VALIDATE CONSTRAINT.+foo/)
- expect(model).to receive(:execute).with(/RESET ALL/)
+ expect(model).to receive(:execute).ordered.with(/RESET ALL/)
model.add_concurrent_foreign_key(:projects, :users, column: :user_id, name: :foo)
end
@@ -321,10 +326,11 @@ describe Gitlab::Database::MigrationHelpers do
it 'creates a new foreign key' do
expect(model).to receive(:with_lock_retries).and_call_original
expect(model).to receive(:disable_statement_timeout).and_call_original
+ expect(model).to receive(:statement_timeout_disabled?).and_return(false)
expect(model).to receive(:execute).with(/statement_timeout/)
expect(model).to receive(:execute).ordered.with(/NOT VALID/)
expect(model).to receive(:execute).ordered.with(/VALIDATE CONSTRAINT.+bar/)
- expect(model).to receive(:execute).with(/RESET ALL/)
+ expect(model).to receive(:execute).ordered.with(/RESET ALL/)
model.add_concurrent_foreign_key(:projects, :users, column: :user_id, name: :bar)
end
@@ -361,6 +367,7 @@ describe Gitlab::Database::MigrationHelpers do
aggregate_failures do
expect(model).not_to receive(:concurrent_foreign_key_name)
expect(model).to receive(:disable_statement_timeout).and_call_original
+ expect(model).to receive(:statement_timeout_disabled?).and_return(false)
expect(model).to receive(:execute).with(/statement_timeout/)
expect(model).to receive(:execute).ordered.with(/ALTER TABLE projects VALIDATE CONSTRAINT/)
expect(model).to receive(:execute).ordered.with(/RESET ALL/)
@@ -377,6 +384,7 @@ describe Gitlab::Database::MigrationHelpers do
aggregate_failures do
expect(model).to receive(:concurrent_foreign_key_name)
expect(model).to receive(:disable_statement_timeout).and_call_original
+ expect(model).to receive(:statement_timeout_disabled?).and_return(false)
expect(model).to receive(:execute).with(/statement_timeout/)
expect(model).to receive(:execute).ordered.with(/ALTER TABLE projects VALIDATE CONSTRAINT/)
expect(model).to receive(:execute).ordered.with(/RESET ALL/)
@@ -527,6 +535,26 @@ describe Gitlab::Database::MigrationHelpers do
end
end
end
+
+ # This spec runs without an enclosing transaction (:delete truncation method for db_cleaner)
+ context 'when the statement_timeout is already disabled', :delete do
+ before do
+ ActiveRecord::Base.connection.execute('SET statement_timeout TO 0')
+ end
+
+ after do
+ # Use ActiveRecord::Base.connection instead of model.execute
+ # so that this call is not counted below
+ ActiveRecord::Base.connection.execute('RESET ALL')
+ end
+
+ it 'yields control without disabling the timeout or resetting' do
+ expect(model).not_to receive(:execute).with('SET statement_timeout TO 0')
+ expect(model).not_to receive(:execute).with('RESET ALL')
+
+ expect { |block| model.disable_statement_timeout(&block) }.to yield_control
+ end
+ end
end
describe '#true_value' do
@@ -596,140 +624,12 @@ describe Gitlab::Database::MigrationHelpers do
describe '#add_column_with_default' do
let(:column) { Project.columns.find { |c| c.name == "id" } }
- context 'outside of a transaction' do
- context 'when a column limit is not set' do
- before do
- expect(model).to receive(:transaction_open?)
- .and_return(false)
- .at_least(:once)
-
- expect(model).to receive(:transaction).and_yield
-
- expect(model).to receive(:add_column)
- .with(:projects, :foo, :integer, default: nil)
-
- expect(model).to receive(:change_column_default)
- .with(:projects, :foo, 10)
-
- expect(model).to receive(:column_for)
- .with(:projects, :foo).and_return(column)
- end
-
- it 'adds the column while allowing NULL values' do
- expect(model).to receive(:update_column_in_batches)
- .with(:projects, :foo, 10)
-
- expect(model).not_to receive(:change_column_null)
-
- model.add_column_with_default(:projects, :foo, :integer,
- default: 10,
- allow_null: true)
- end
-
- it 'adds the column while not allowing NULL values' do
- expect(model).to receive(:update_column_in_batches)
- .with(:projects, :foo, 10)
-
- expect(model).to receive(:change_column_null)
- .with(:projects, :foo, false)
-
- model.add_column_with_default(:projects, :foo, :integer, default: 10)
- end
-
- it 'removes the added column whenever updating the rows fails' do
- expect(model).to receive(:update_column_in_batches)
- .with(:projects, :foo, 10)
- .and_raise(RuntimeError)
-
- expect(model).to receive(:remove_column)
- .with(:projects, :foo)
-
- expect do
- model.add_column_with_default(:projects, :foo, :integer, default: 10)
- end.to raise_error(RuntimeError)
- end
-
- it 'removes the added column whenever changing a column NULL constraint fails' do
- expect(model).to receive(:change_column_null)
- .with(:projects, :foo, false)
- .and_raise(RuntimeError)
-
- expect(model).to receive(:remove_column)
- .with(:projects, :foo)
-
- expect do
- model.add_column_with_default(:projects, :foo, :integer, default: 10)
- end.to raise_error(RuntimeError)
- end
- end
-
- context 'when `update_column_in_batches_args` is given' do
- let(:column) { UserDetail.columns.find { |c| c.name == "user_id" } }
-
- it 'uses `user_id` for `update_column_in_batches`' do
- allow(model).to receive(:transaction_open?).and_return(false)
- allow(model).to receive(:transaction).and_yield
- allow(model).to receive(:column_for).with(:user_details, :foo).and_return(column)
- allow(model).to receive(:update_column_in_batches).with(:user_details, :foo, 10, batch_column_name: :user_id)
- allow(model).to receive(:change_column_null).with(:user_details, :foo, false)
- allow(model).to receive(:change_column_default).with(:user_details, :foo, 10)
+ it 'delegates to #add_column' do
+ expect(model).to receive(:add_column).with(:projects, :foo, :integer, default: 10, limit: nil, null: true)
- expect(model).to receive(:add_column)
- .with(:user_details, :foo, :integer, default: nil)
-
- model.add_column_with_default(
- :user_details,
- :foo,
- :integer,
- default: 10,
- update_column_in_batches_args: { batch_column_name: :user_id }
- )
- end
- end
-
- context 'when a column limit is set' do
- it 'adds the column with a limit' do
- allow(model).to receive(:transaction_open?).and_return(false)
- allow(model).to receive(:transaction).and_yield
- allow(model).to receive(:column_for).with(:projects, :foo).and_return(column)
- allow(model).to receive(:update_column_in_batches).with(:projects, :foo, 10)
- allow(model).to receive(:change_column_null).with(:projects, :foo, false)
- allow(model).to receive(:change_column_default).with(:projects, :foo, 10)
-
- expect(model).to receive(:add_column)
- .with(:projects, :foo, :integer, default: nil, limit: 8)
-
- model.add_column_with_default(:projects, :foo, :integer, default: 10, limit: 8)
- end
- end
-
- it 'adds a column with an array default value for a jsonb type' do
- create(:project)
- allow(model).to receive(:transaction_open?).and_return(false)
- allow(model).to receive(:transaction).and_yield
- expect(model).to receive(:update_column_in_batches).with(:projects, :foo, '[{"foo":"json"}]').and_call_original
-
- model.add_column_with_default(:projects, :foo, :jsonb, default: [{ foo: "json" }])
- end
-
- it 'adds a column with an object default value for a jsonb type' do
- create(:project)
- allow(model).to receive(:transaction_open?).and_return(false)
- allow(model).to receive(:transaction).and_yield
- expect(model).to receive(:update_column_in_batches).with(:projects, :foo, '{"foo":"json"}').and_call_original
-
- model.add_column_with_default(:projects, :foo, :jsonb, default: { foo: "json" })
- end
- end
-
- context 'inside a transaction' do
- it 'raises RuntimeError' do
- expect(model).to receive(:transaction_open?).and_return(true)
-
- expect do
- model.add_column_with_default(:projects, :foo, :integer, default: 10)
- end.to raise_error(RuntimeError)
- end
+ model.add_column_with_default(:projects, :foo, :integer,
+ default: 10,
+ allow_null: true)
end
end
@@ -782,7 +682,7 @@ describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:update_column_in_batches)
- expect(model).to receive(:change_column_null).with(:users, :new, false)
+ expect(model).to receive(:add_not_null_constraint).with(:users, :new)
expect(model).to receive(:copy_indexes).with(:users, :old, :new)
expect(model).to receive(:copy_foreign_keys).with(:users, :old, :new)
@@ -790,6 +690,25 @@ describe Gitlab::Database::MigrationHelpers do
model.rename_column_concurrently(:users, :old, :new)
end
+ it 'passes the batch_column_name' do
+ expect(model).to receive(:column_exists?).with(:users, :other_batch_column).and_return(true)
+ expect(model).to receive(:check_trigger_permissions!).and_return(true)
+
+ expect(model).to receive(:create_column_from).with(
+ :users, :old, :new, type: nil, batch_column_name: :other_batch_column
+ ).and_return(true)
+
+ expect(model).to receive(:install_rename_triggers).and_return(true)
+
+ model.rename_column_concurrently(:users, :old, :new, batch_column_name: :other_batch_column)
+ end
+
+ it 'raises an error with invalid batch_column_name' do
+ expect do
+ model.rename_column_concurrently(:users, :old, :new, batch_column_name: :invalid)
+ end.to raise_error(RuntimeError, /Column invalid does not exist on users/)
+ end
+
context 'when default is false' do
let(:old_column) do
double(:column,
@@ -896,7 +815,7 @@ describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:update_column_in_batches)
- expect(model).to receive(:change_column_null).with(:users, :old, false)
+ expect(model).to receive(:add_not_null_constraint).with(:users, :old)
expect(model).to receive(:copy_indexes).with(:users, :new, :old)
expect(model).to receive(:copy_foreign_keys).with(:users, :new, :old)
@@ -904,6 +823,25 @@ describe Gitlab::Database::MigrationHelpers do
model.undo_cleanup_concurrent_column_rename(:users, :old, :new)
end
+ it 'passes the batch_column_name' do
+ expect(model).to receive(:column_exists?).with(:users, :other_batch_column).and_return(true)
+ expect(model).to receive(:check_trigger_permissions!).and_return(true)
+
+ expect(model).to receive(:create_column_from).with(
+ :users, :new, :old, type: nil, batch_column_name: :other_batch_column
+ ).and_return(true)
+
+ expect(model).to receive(:install_rename_triggers).and_return(true)
+
+ model.undo_cleanup_concurrent_column_rename(:users, :old, :new, batch_column_name: :other_batch_column)
+ end
+
+ it 'raises an error with invalid batch_column_name' do
+ expect do
+ model.undo_cleanup_concurrent_column_rename(:users, :old, :new, batch_column_name: :invalid)
+ end.to raise_error(RuntimeError, /Column invalid does not exist on users/)
+ end
+
context 'when default is false' do
let(:new_column) do
double(:column,
@@ -1365,6 +1303,22 @@ describe Gitlab::Database::MigrationHelpers do
end
end
+ it 'returns the final expected delay' do
+ Sidekiq::Testing.fake! do
+ final_delay = model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, batch_size: 2)
+
+ expect(final_delay.to_f).to eq(20.minutes.to_f)
+ end
+ end
+
+ it 'returns zero when nothing gets queued' do
+ Sidekiq::Testing.fake! do
+ final_delay = model.queue_background_migration_jobs_by_range_at_intervals(User.none, 'FooJob', 10.minutes)
+
+ expect(final_delay).to eq(0)
+ end
+ end
+
context 'with batch_size option' do
it 'queues jobs correctly' do
Sidekiq::Testing.fake! do
@@ -1389,12 +1343,25 @@ describe Gitlab::Database::MigrationHelpers do
end
end
- context 'with other_arguments option' do
+ context 'with other_job_arguments option' do
+ it 'queues jobs correctly' do
+ Sidekiq::Testing.fake! do
+ model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, other_job_arguments: [1, 2])
+
+ expect(BackgroundMigrationWorker.jobs[0]['args']).to eq(['FooJob', [id1, id3, 1, 2]])
+ expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(10.minutes.from_now.to_f)
+ end
+ end
+ end
+
+ context 'with initial_delay option' do
it 'queues jobs correctly' do
- model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, other_arguments: [1, 2])
+ Sidekiq::Testing.fake! do
+ model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, other_job_arguments: [1, 2], initial_delay: 10.minutes)
- expect(BackgroundMigrationWorker.jobs[0]['args']).to eq(['FooJob', [id1, id3, 1, 2]])
- expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(10.minutes.from_now.to_f)
+ expect(BackgroundMigrationWorker.jobs[0]['args']).to eq(['FooJob', [id1, id3, 1, 2]])
+ expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(20.minutes.from_now.to_f)
+ end
end
end
end
@@ -2158,6 +2125,7 @@ describe Gitlab::Database::MigrationHelpers do
.and_return(false).exactly(1)
expect(model).to receive(:disable_statement_timeout).and_call_original
+ expect(model).to receive(:statement_timeout_disabled?).and_return(false)
expect(model).to receive(:execute).with(/statement_timeout/)
expect(model).to receive(:with_lock_retries).and_call_original
expect(model).to receive(:execute).with(/ADD CONSTRAINT check_name_not_null/)
@@ -2201,6 +2169,7 @@ describe Gitlab::Database::MigrationHelpers do
.and_return(false).exactly(1)
expect(model).to receive(:disable_statement_timeout).and_call_original
+ expect(model).to receive(:statement_timeout_disabled?).and_return(false)
expect(model).to receive(:execute).with(/statement_timeout/)
expect(model).to receive(:with_lock_retries).and_call_original
expect(model).to receive(:execute).with(/ADD CONSTRAINT check_name_not_null/)
@@ -2242,6 +2211,7 @@ describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:check_constraint_exists?).and_return(true)
expect(model).to receive(:disable_statement_timeout).and_call_original
+ expect(model).to receive(:statement_timeout_disabled?).and_return(false)
expect(model).to receive(:execute).with(/statement_timeout/)
expect(model).to receive(:execute).ordered.with(validate_sql)
expect(model).to receive(:execute).ordered.with(/RESET ALL/)
@@ -2381,4 +2351,135 @@ describe Gitlab::Database::MigrationHelpers do
end
end
end
+
+ describe '#add_not_null_constraint' do
+ context 'when it is called with the default options' do
+ it 'calls add_check_constraint with an infered constraint name and validate: true' do
+ constraint_name = model.check_constraint_name(:test_table,
+ :name,
+ 'not_null')
+ check = "name IS NOT NULL"
+
+ expect(model).to receive(:column_is_nullable?).and_return(true)
+ expect(model).to receive(:check_constraint_name).and_call_original
+ expect(model).to receive(:add_check_constraint)
+ .with(:test_table, check, constraint_name, validate: true)
+
+ model.add_not_null_constraint(:test_table, :name)
+ end
+ end
+
+ context 'when all parameters are provided' do
+ it 'calls add_check_constraint with the correct parameters' do
+ constraint_name = 'check_name_not_null'
+ check = "name IS NOT NULL"
+
+ expect(model).to receive(:column_is_nullable?).and_return(true)
+ expect(model).not_to receive(:check_constraint_name)
+ expect(model).to receive(:add_check_constraint)
+ .with(:test_table, check, constraint_name, validate: false)
+
+ model.add_not_null_constraint(
+ :test_table,
+ :name,
+ constraint_name: constraint_name,
+ validate: false
+ )
+ end
+ end
+
+ context 'when the column is defined as NOT NULL' do
+ it 'does not add a check constraint' do
+ expect(model).to receive(:column_is_nullable?).and_return(false)
+ expect(model).not_to receive(:check_constraint_name)
+ expect(model).not_to receive(:add_check_constraint)
+
+ model.add_not_null_constraint(:test_table, :name)
+ end
+ end
+ end
+
+ describe '#validate_not_null_constraint' do
+ context 'when constraint_name is not provided' do
+ it 'calls validate_check_constraint with an infered constraint name' do
+ constraint_name = model.check_constraint_name(:test_table,
+ :name,
+ 'not_null')
+
+ expect(model).to receive(:check_constraint_name).and_call_original
+ expect(model).to receive(:validate_check_constraint)
+ .with(:test_table, constraint_name)
+
+ model.validate_not_null_constraint(:test_table, :name)
+ end
+ end
+
+ context 'when constraint_name is provided' do
+ it 'calls validate_check_constraint with the correct parameters' do
+ constraint_name = 'check_name_not_null'
+
+ expect(model).not_to receive(:check_constraint_name)
+ expect(model).to receive(:validate_check_constraint)
+ .with(:test_table, constraint_name)
+
+ model.validate_not_null_constraint(:test_table, :name, constraint_name: constraint_name)
+ end
+ end
+ end
+
+ describe '#remove_not_null_constraint' do
+ context 'when constraint_name is not provided' do
+ it 'calls remove_check_constraint with an infered constraint name' do
+ constraint_name = model.check_constraint_name(:test_table,
+ :name,
+ 'not_null')
+
+ expect(model).to receive(:check_constraint_name).and_call_original
+ expect(model).to receive(:remove_check_constraint)
+ .with(:test_table, constraint_name)
+
+ model.remove_not_null_constraint(:test_table, :name)
+ end
+ end
+
+ context 'when constraint_name is provided' do
+ it 'calls remove_check_constraint with the correct parameters' do
+ constraint_name = 'check_name_not_null'
+
+ expect(model).not_to receive(:check_constraint_name)
+ expect(model).to receive(:remove_check_constraint)
+ .with(:test_table, constraint_name)
+
+ model.remove_not_null_constraint(:test_table, :name, constraint_name: constraint_name)
+ end
+ end
+ end
+
+ describe '#check_not_null_constraint_exists?' do
+ context 'when constraint_name is not provided' do
+ it 'calls check_constraint_exists? with an infered constraint name' do
+ constraint_name = model.check_constraint_name(:test_table,
+ :name,
+ 'not_null')
+
+ expect(model).to receive(:check_constraint_name).and_call_original
+ expect(model).to receive(:check_constraint_exists?)
+ .with(:test_table, constraint_name)
+
+ model.check_not_null_constraint_exists?(:test_table, :name)
+ end
+ end
+
+ context 'when constraint_name is provided' do
+ it 'calls check_constraint_exists? with the correct parameters' do
+ constraint_name = 'check_name_not_null'
+
+ expect(model).not_to receive(:check_constraint_name)
+ expect(model).to receive(:check_constraint_exists?)
+ .with(:test_table, constraint_name)
+
+ model.check_not_null_constraint_exists?(:test_table, :name, constraint_name: constraint_name)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/partitioned_foreign_key_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/partitioned_foreign_key_spec.rb
new file mode 100644
index 00000000000..77f71676252
--- /dev/null
+++ b/spec/lib/gitlab/database/partitioning_migration_helpers/partitioned_foreign_key_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Database::PartitioningMigrationHelpers::PartitionedForeignKey do
+ let(:foreign_key) do
+ described_class.new(
+ to_table: 'issues',
+ from_table: 'issue_assignees',
+ from_column: 'issue_id',
+ to_column: 'id',
+ cascade_delete: true)
+ end
+
+ describe 'validations' do
+ it 'allows keys that reference valid tables and columns' do
+ expect(foreign_key).to be_valid
+ end
+
+ it 'does not allow keys without a valid to_table' do
+ foreign_key.to_table = 'this_is_not_a_real_table'
+
+ expect(foreign_key).not_to be_valid
+ expect(foreign_key.errors[:to_table].first).to eq('must be a valid table')
+ end
+
+ it 'does not allow keys without a valid from_table' do
+ foreign_key.from_table = 'this_is_not_a_real_table'
+
+ expect(foreign_key).not_to be_valid
+ expect(foreign_key.errors[:from_table].first).to eq('must be a valid table')
+ end
+
+ it 'does not allow keys without a valid to_column' do
+ foreign_key.to_column = 'this_is_not_a_real_fk'
+
+ expect(foreign_key).not_to be_valid
+ expect(foreign_key.errors[:to_column].first).to eq('must be a valid column')
+ end
+
+ it 'does not allow keys without a valid from_column' do
+ foreign_key.from_column = 'this_is_not_a_real_pk'
+
+ expect(foreign_key).not_to be_valid
+ expect(foreign_key.errors[:from_column].first).to eq('must be a valid column')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers_spec.rb
new file mode 100644
index 00000000000..0e2fb047469
--- /dev/null
+++ b/spec/lib/gitlab/database/partitioning_migration_helpers_spec.rb
@@ -0,0 +1,230 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Database::PartitioningMigrationHelpers do
+ let(:model) do
+ ActiveRecord::Migration.new.extend(described_class)
+ end
+ let_it_be(:connection) { ActiveRecord::Base.connection }
+ let(:referenced_table) { :issues }
+ let(:function_name) { model.fk_function_name(referenced_table) }
+ let(:trigger_name) { model.fk_trigger_name(referenced_table) }
+
+ before do
+ allow(model).to receive(:puts)
+ end
+
+ describe 'adding a foreign key' do
+ before do
+ allow(model).to receive(:transaction_open?).and_return(false)
+ end
+
+ context 'when the table has no foreign keys' do
+ it 'creates a trigger function to handle the single cascade' do
+ model.add_partitioned_foreign_key :issue_assignees, referenced_table
+
+ expect_function_to_contain(function_name, 'delete from issue_assignees where issue_id = old.id')
+ expect_valid_function_trigger(trigger_name, function_name)
+ end
+ end
+
+ context 'when the table already has foreign keys' do
+ context 'when the foreign key is from a different table' do
+ before do
+ model.add_partitioned_foreign_key :issue_assignees, referenced_table
+ end
+
+ it 'creates a trigger function to handle the multiple cascades' do
+ model.add_partitioned_foreign_key :epic_issues, referenced_table
+
+ expect_function_to_contain(function_name,
+ 'delete from issue_assignees where issue_id = old.id',
+ 'delete from epic_issues where issue_id = old.id')
+ expect_valid_function_trigger(trigger_name, function_name)
+ end
+ end
+
+ context 'when the foreign key is from the same table' do
+ before do
+ model.add_partitioned_foreign_key :issues, referenced_table, column: :moved_to_id
+ end
+
+ context 'when the foreign key is from a different column' do
+ it 'creates a trigger function to handle the multiple cascades' do
+ model.add_partitioned_foreign_key :issues, referenced_table, column: :duplicated_to_id
+
+ expect_function_to_contain(function_name,
+ 'delete from issues where moved_to_id = old.id',
+ 'delete from issues where duplicated_to_id = old.id')
+ expect_valid_function_trigger(trigger_name, function_name)
+ end
+ end
+
+ context 'when the foreign key is from the same column' do
+ it 'ignores the duplicate and properly recreates the trigger function' do
+ model.add_partitioned_foreign_key :issues, referenced_table, column: :moved_to_id
+
+ expect_function_to_contain(function_name, 'delete from issues where moved_to_id = old.id')
+ expect_valid_function_trigger(trigger_name, function_name)
+ end
+ end
+ end
+ end
+
+ context 'when the foreign key is set to nullify' do
+ it 'creates a trigger function that nullifies the foreign key' do
+ model.add_partitioned_foreign_key :issue_assignees, referenced_table, on_delete: :nullify
+
+ expect_function_to_contain(function_name, 'update issue_assignees set issue_id = null where issue_id = old.id')
+ expect_valid_function_trigger(trigger_name, function_name)
+ end
+ end
+
+ context 'when the referencing column is a custom value' do
+ it 'creates a trigger function with the correct column name' do
+ model.add_partitioned_foreign_key :issues, referenced_table, column: :duplicated_to_id
+
+ expect_function_to_contain(function_name, 'delete from issues where duplicated_to_id = old.id')
+ expect_valid_function_trigger(trigger_name, function_name)
+ end
+ end
+
+ context 'when the referenced column is a custom value' do
+ let(:referenced_table) { :user_details }
+
+ it 'creates a trigger function with the correct column name' do
+ model.add_partitioned_foreign_key :user_preferences, referenced_table, column: :user_id, primary_key: :user_id
+
+ expect_function_to_contain(function_name, 'delete from user_preferences where user_id = old.user_id')
+ expect_valid_function_trigger(trigger_name, function_name)
+ end
+ end
+
+ context 'when the given key definition is invalid' do
+ it 'raises an error with the appropriate message' do
+ expect do
+ model.add_partitioned_foreign_key :issue_assignees, referenced_table, column: :not_a_real_issue_id
+ end.to raise_error(/From column must be a valid column/)
+ end
+ end
+
+ context 'when run inside a transaction' do
+ it 'raises an error' do
+ expect(model).to receive(:transaction_open?).and_return(true)
+
+ expect do
+ model.add_partitioned_foreign_key :issue_assignees, referenced_table
+ end.to raise_error(/can not be run inside a transaction/)
+ end
+ end
+ end
+
+ context 'removing a foreign key' do
+ before do
+ allow(model).to receive(:transaction_open?).and_return(false)
+ end
+
+ context 'when the table has multiple foreign keys' do
+ before do
+ model.add_partitioned_foreign_key :issue_assignees, referenced_table
+ model.add_partitioned_foreign_key :epic_issues, referenced_table
+ end
+
+ it 'creates a trigger function without the removed cascade' do
+ expect_function_to_contain(function_name,
+ 'delete from issue_assignees where issue_id = old.id',
+ 'delete from epic_issues where issue_id = old.id')
+ expect_valid_function_trigger(trigger_name, function_name)
+
+ model.remove_partitioned_foreign_key :issue_assignees, referenced_table
+
+ expect_function_to_contain(function_name, 'delete from epic_issues where issue_id = old.id')
+ expect_valid_function_trigger(trigger_name, function_name)
+ end
+ end
+
+ context 'when the table has only one remaining foreign key' do
+ before do
+ model.add_partitioned_foreign_key :issue_assignees, referenced_table
+ end
+
+ it 'removes the trigger function altogether' do
+ expect_function_to_contain(function_name, 'delete from issue_assignees where issue_id = old.id')
+ expect_valid_function_trigger(trigger_name, function_name)
+
+ model.remove_partitioned_foreign_key :issue_assignees, referenced_table
+
+ expect(find_function_def(function_name)).to be_nil
+ expect(find_trigger_def(trigger_name)).to be_nil
+ end
+ end
+
+ context 'when the foreign key does not exist' do
+ before do
+ model.add_partitioned_foreign_key :issue_assignees, referenced_table
+ end
+
+ it 'ignores the invalid key and properly recreates the trigger function' do
+ expect_function_to_contain(function_name, 'delete from issue_assignees where issue_id = old.id')
+ expect_valid_function_trigger(trigger_name, function_name)
+
+ model.remove_partitioned_foreign_key :issues, referenced_table, column: :moved_to_id
+
+ expect_function_to_contain(function_name, 'delete from issue_assignees where issue_id = old.id')
+ expect_valid_function_trigger(trigger_name, function_name)
+ end
+ end
+
+ context 'when run outside a transaction' do
+ it 'raises an error' do
+ expect(model).to receive(:transaction_open?).and_return(true)
+
+ expect do
+ model.remove_partitioned_foreign_key :issue_assignees, referenced_table
+ end.to raise_error(/can not be run inside a transaction/)
+ end
+ end
+ end
+
+ def expect_function_to_contain(name, *statements)
+ return_stmt, *body_stmts = parsed_function_statements(name).reverse
+
+ expect(return_stmt).to eq('return old')
+ expect(body_stmts).to contain_exactly(*statements)
+ end
+
+ def expect_valid_function_trigger(name, fn_name)
+ event, activation, definition = cleaned_trigger_def(name)
+
+ expect(event).to eq('delete')
+ expect(activation).to eq('after')
+ expect(definition).to eq("execute procedure #{fn_name}()")
+ end
+
+ def parsed_function_statements(name)
+ cleaned_definition = find_function_def(name)['fn_body'].downcase.gsub(/\s+/, ' ')
+ statements = cleaned_definition.sub(/\A\s*begin\s*(.*)\s*end\s*\Z/, "\\1")
+ statements.split(';').map! { |stmt| stmt.strip.presence }.compact!
+ end
+
+ def find_function_def(name)
+ connection.execute("select prosrc as fn_body from pg_proc where proname = '#{name}';").first
+ end
+
+ def cleaned_trigger_def(name)
+ find_trigger_def(name).values_at('event', 'activation', 'definition').map!(&:downcase)
+ end
+
+ def find_trigger_def(name)
+ connection.execute(<<~SQL).first
+ select
+ string_agg(event_manipulation, ',') as event,
+ action_timing as activation,
+ action_statement as definition
+ from information_schema.triggers
+ where trigger_name = '#{name}'
+ group by 2, 3
+ SQL
+ end
+end
diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb
index 7b8437e4874..fae57996fb6 100644
--- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb
+++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb
@@ -242,7 +242,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, :delete
old_path, new_path = [nil, nil]
Gitlab::Redis::SharedState.with do |redis|
rename_info = redis.lpop(key)
- old_path, new_path = JSON.parse(rename_info)
+ old_path, new_path = Gitlab::Json.parse(rename_info)
end
expect(old_path).to eq('path/to/namespace')
@@ -278,7 +278,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, :delete
end
expect(rename_count).to eq(1)
- expect(JSON.parse(stored_renames.first)).to eq(%w(old_path new_path))
+ expect(Gitlab::Json.parse(stored_renames.first)).to eq(%w(old_path new_path))
end
end
end
diff --git a/spec/lib/gitlab/database/with_lock_retries_spec.rb b/spec/lib/gitlab/database/with_lock_retries_spec.rb
index b6321f2eab1..9c8c9749125 100644
--- a/spec/lib/gitlab/database/with_lock_retries_spec.rb
+++ b/spec/lib/gitlab/database/with_lock_retries_spec.rb
@@ -84,7 +84,7 @@ describe Gitlab::Database::WithLockRetries do
subject.run do
lock_attempts += 1
- if lock_attempts == retry_count # we reached the last retry iteration, if we kill the thread, the last try (no lock_timeout) will succeed)
+ if lock_attempts == retry_count # we reached the last retry iteration, if we kill the thread, the last try (no lock_timeout) will succeed
lock_fiber.resume
end
@@ -106,9 +106,13 @@ describe Gitlab::Database::WithLockRetries do
end
context 'after the retries, without setting lock_timeout' do
- let(:retry_count) { timing_configuration.size }
+ let(:retry_count) { timing_configuration.size + 1 }
- it_behaves_like 'retriable exclusive lock on `projects`'
+ it_behaves_like 'retriable exclusive lock on `projects`' do
+ before do
+ expect(subject).to receive(:run_block_without_lock_timeout).and_call_original
+ end
+ end
end
context 'when statement timeout is reached' do
@@ -129,11 +133,22 @@ describe Gitlab::Database::WithLockRetries do
end
end
+ context 'restore local database variables' do
+ it do
+ expect { subject.run {} }.not_to change { ActiveRecord::Base.connection.execute("SHOW lock_timeout").to_a }
+ end
+
+ it do
+ expect { subject.run {} }.not_to change { ActiveRecord::Base.connection.execute("SHOW idle_in_transaction_session_timeout").to_a }
+ end
+ end
+
context 'casting durations correctly' do
let(:timing_configuration) { [[0.015.seconds, 0.025.seconds], [0.015.seconds, 0.025.seconds]] } # 15ms, 25ms
it 'executes `SET LOCAL lock_timeout` using the configured timeout value in milliseconds' do
expect(ActiveRecord::Base.connection).to receive(:execute).with("SAVEPOINT active_record_1").and_call_original
+ expect(ActiveRecord::Base.connection).to receive(:execute).with('RESET idle_in_transaction_session_timeout; RESET lock_timeout').and_call_original
expect(ActiveRecord::Base.connection).to receive(:execute).with("SET LOCAL lock_timeout TO '15ms'").and_call_original
expect(ActiveRecord::Base.connection).to receive(:execute).with("RELEASE SAVEPOINT active_record_1").and_call_original
diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb
index 61d7400b95e..d1592e60d3d 100644
--- a/spec/lib/gitlab/diff/file_spec.rb
+++ b/spec/lib/gitlab/diff/file_spec.rb
@@ -567,6 +567,61 @@ describe Gitlab::Diff::File do
end
end
+ describe '#alternate_viewer' do
+ subject { diff_file.alternate_viewer }
+
+ where(:viewer_class) do
+ [
+ DiffViewer::Image,
+ DiffViewer::Collapsed,
+ DiffViewer::NotDiffable,
+ DiffViewer::Text,
+ DiffViewer::NoPreview,
+ DiffViewer::Added,
+ DiffViewer::Deleted,
+ DiffViewer::ModeChanged,
+ DiffViewer::ModeChanged,
+ DiffViewer::NoPreview
+ ]
+ end
+
+ with_them do
+ let(:viewer) { viewer_class.new(diff_file) }
+
+ before do
+ allow(diff_file).to receive(:viewer).and_return(viewer)
+ end
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when viewer is DiffViewer::Renamed' do
+ let(:viewer) { DiffViewer::Renamed.new(diff_file) }
+
+ before do
+ allow(diff_file).to receive(:viewer).and_return(viewer)
+ end
+
+ context 'when it can be rendered as text' do
+ it { is_expected.to be_a(DiffViewer::Text) }
+ end
+
+ context 'when it can be rendered as image' do
+ let(:commit) { project.commit('2f63565e7aac07bcdadb654e253078b727143ec4') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('files/images/6049019_460s.jpg') }
+
+ it { is_expected.to be_a(DiffViewer::Image) }
+ end
+
+ context 'when it is something else' do
+ let(:commit) { project.commit('ae73cb07c9eeaf35924a10f713b364d32b2dd34f') }
+ let(:diff_file) { commit.diffs.diff_file_with_new_path('Gemfile.zip') }
+
+ it { is_expected.to be_nil }
+ end
+ end
+ end
+
describe '#rendered_as_text?' do
context 'when the simple viewer is text-based' do
let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') }
diff --git a/spec/lib/gitlab/diff/formatters/text_formatter_spec.rb b/spec/lib/gitlab/diff/formatters/text_formatter_spec.rb
index e275ebef2c9..fa129a20e58 100644
--- a/spec/lib/gitlab/diff/formatters/text_formatter_spec.rb
+++ b/spec/lib/gitlab/diff/formatters/text_formatter_spec.rb
@@ -26,6 +26,7 @@ describe Gitlab::Diff::Formatters::TextFormatter do
# Specific text formatter examples
let!(:formatter) { described_class.new(attrs) }
+ let(:attrs) { base }
describe '#line_age' do
subject { formatter.line_age }
@@ -42,4 +43,21 @@ describe Gitlab::Diff::Formatters::TextFormatter do
it { is_expected.to eq('old') }
end
end
+
+ describe "#==" do
+ it "is false when the line_range changes" do
+ formatter_1 = described_class.new(base.merge(line_range: { start_line_code: "foo", end_line_code: "bar" }))
+ formatter_2 = described_class.new(base.merge(line_range: { start_line_code: "foo", end_line_code: "baz" }))
+
+ expect(formatter_1).not_to eq(formatter_2)
+ end
+
+ it "is true when the line_range doesn't change" do
+ attrs = base.merge({ line_range: { start_line_code: "foo", end_line_code: "baz" } })
+ formatter_1 = described_class.new(attrs)
+ formatter_2 = described_class.new(attrs)
+
+ expect(formatter_1).to eq(formatter_2)
+ end
+ end
end
diff --git a/spec/lib/gitlab/diff/position_spec.rb b/spec/lib/gitlab/diff/position_spec.rb
index a83c0f35d92..10749ec024d 100644
--- a/spec/lib/gitlab/diff/position_spec.rb
+++ b/spec/lib/gitlab/diff/position_spec.rb
@@ -639,11 +639,11 @@ describe Gitlab::Diff::Position do
let(:diff_position) { described_class.new(args) }
it "returns the position as JSON" do
- expect(JSON.parse(diff_position.to_json)).to eq(args.stringify_keys)
+ expect(Gitlab::Json.parse(diff_position.to_json)).to eq(args.stringify_keys)
end
it "works when nested under another hash" do
- expect(JSON.parse(JSON.generate(pos: diff_position))).to eq('pos' => args.stringify_keys)
+ expect(Gitlab::Json.parse(Gitlab::Json.generate(pos: diff_position))).to eq('pos' => args.stringify_keys)
end
end
diff --git a/spec/lib/gitlab/elasticsearch/logs/lines_spec.rb b/spec/lib/gitlab/elasticsearch/logs/lines_spec.rb
index 8b6a19fa2c5..45a262c0e77 100644
--- a/spec/lib/gitlab/elasticsearch/logs/lines_spec.rb
+++ b/spec/lib/gitlab/elasticsearch/logs/lines_spec.rb
@@ -10,7 +10,7 @@ describe Gitlab::Elasticsearch::Logs::Lines do
let(:es_message_3) { { timestamp: "2019-12-13T14:35:36.034Z", pod: "production-6866bc8974-m4sk4", message: "10.8.2.1 - - [04/Nov/2019:23:09:24 UTC] \"GET / HTTP/1.1\" 200 13" } }
let(:es_message_4) { { timestamp: "2019-12-13T14:35:37.034Z", pod: "production-6866bc8974-m4sk4", message: "- -\u003e /" } }
- let(:es_response) { JSON.parse(fixture_file('lib/elasticsearch/logs_response.json')) }
+ let(:es_response) { Gitlab::Json.parse(fixture_file('lib/elasticsearch/logs_response.json')) }
subject { described_class.new(client) }
@@ -22,13 +22,14 @@ describe Gitlab::Elasticsearch::Logs::Lines do
let(:end_time) { "2019-12-13T14:35:34.034Z" }
let(:cursor) { "9999934,1572449784442" }
- let(:body) { JSON.parse(fixture_file('lib/elasticsearch/query.json')) }
- let(:body_with_container) { JSON.parse(fixture_file('lib/elasticsearch/query_with_container.json')) }
- let(:body_with_search) { JSON.parse(fixture_file('lib/elasticsearch/query_with_search.json')) }
- let(:body_with_times) { JSON.parse(fixture_file('lib/elasticsearch/query_with_times.json')) }
- let(:body_with_start_time) { JSON.parse(fixture_file('lib/elasticsearch/query_with_start_time.json')) }
- let(:body_with_end_time) { JSON.parse(fixture_file('lib/elasticsearch/query_with_end_time.json')) }
- let(:body_with_cursor) { JSON.parse(fixture_file('lib/elasticsearch/query_with_cursor.json')) }
+ let(:body) { Gitlab::Json.parse(fixture_file('lib/elasticsearch/query.json')) }
+ let(:body_with_container) { Gitlab::Json.parse(fixture_file('lib/elasticsearch/query_with_container.json')) }
+ let(:body_with_search) { Gitlab::Json.parse(fixture_file('lib/elasticsearch/query_with_search.json')) }
+ let(:body_with_times) { Gitlab::Json.parse(fixture_file('lib/elasticsearch/query_with_times.json')) }
+ let(:body_with_start_time) { Gitlab::Json.parse(fixture_file('lib/elasticsearch/query_with_start_time.json')) }
+ let(:body_with_end_time) { Gitlab::Json.parse(fixture_file('lib/elasticsearch/query_with_end_time.json')) }
+ let(:body_with_cursor) { Gitlab::Json.parse(fixture_file('lib/elasticsearch/query_with_cursor.json')) }
+ let(:body_with_filebeat_6) { Gitlab::Json.parse(fixture_file('lib/elasticsearch/query_with_filebeat_6.json')) }
RSpec::Matchers.define :a_hash_equal_to_json do |expected|
match do |actual|
@@ -85,5 +86,12 @@ describe Gitlab::Elasticsearch::Logs::Lines do
result = subject.pod_logs(namespace, pod_name: pod_name, cursor: cursor)
expect(result).to eq(logs: [es_message_4, es_message_3, es_message_2, es_message_1], cursor: cursor)
end
+
+ it 'can search on filebeat 6' do
+ expect(client).to receive(:search).with(body: a_hash_equal_to_json(body_with_filebeat_6)).and_return(es_response)
+
+ result = subject.pod_logs(namespace, pod_name: pod_name, chart_above_v2: false)
+ expect(result).to eq(logs: [es_message_4, es_message_3, es_message_2, es_message_1], cursor: cursor)
+ end
end
end
diff --git a/spec/lib/gitlab/elasticsearch/logs/pods_spec.rb b/spec/lib/gitlab/elasticsearch/logs/pods_spec.rb
index 0a4ab0780c5..c2c3074e965 100644
--- a/spec/lib/gitlab/elasticsearch/logs/pods_spec.rb
+++ b/spec/lib/gitlab/elasticsearch/logs/pods_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
describe Gitlab::Elasticsearch::Logs::Pods do
let(:client) { Elasticsearch::Transport::Client }
- let(:es_query) { JSON.parse(fixture_file('lib/elasticsearch/pods_query.json'), symbolize_names: true) }
- let(:es_response) { JSON.parse(fixture_file('lib/elasticsearch/pods_response.json')) }
+ let(:es_query) { Gitlab::Json.parse(fixture_file('lib/elasticsearch/pods_query.json'), symbolize_names: true) }
+ let(:es_response) { Gitlab::Json.parse(fixture_file('lib/elasticsearch/pods_response.json')) }
let(:namespace) { "autodevops-deploy-9-production" }
subject { described_class.new(client) }
diff --git a/spec/lib/gitlab/email/handler_spec.rb b/spec/lib/gitlab/email/handler_spec.rb
index 5014e4c22ce..6dbf069f07c 100644
--- a/spec/lib/gitlab/email/handler_spec.rb
+++ b/spec/lib/gitlab/email/handler_spec.rb
@@ -6,6 +6,18 @@ describe Gitlab::Email::Handler do
let(:email) { Mail.new { body 'email' } }
describe '.for' do
+ context 'key matches the reply_key of a notification' do
+ it 'picks note handler' do
+ expect(described_class.for(email, '1234567890abcdef1234567890abcdef')).to be_an_instance_of(Gitlab::Email::Handler::CreateNoteHandler)
+ end
+ end
+
+ context 'key matches the reply_key of a notification, along with an unsubscribe suffix' do
+ it 'picks unsubscribe handler' do
+ expect(described_class.for(email, '1234567890abcdef1234567890abcdef-unsubscribe')).to be_an_instance_of(Gitlab::Email::Handler::UnsubscribeHandler)
+ end
+ end
+
it 'picks issue handler if there is no merge request prefix' do
expect(described_class.for(email, 'project+key')).to be_an_instance_of(Gitlab::Email::Handler::CreateIssueHandler)
end
diff --git a/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb b/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb
index 36954252b6b..31ba48e9df1 100644
--- a/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb
+++ b/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb
@@ -5,19 +5,24 @@ require 'spec_helper'
describe Gitlab::Email::Hook::SmimeSignatureInterceptor do
include SmimeHelper
- # cert generation is an expensive operation and they are used read-only,
+ # certs generation is an expensive operation and they are used read-only,
# so we share them as instance variables in all tests
before :context do
@root_ca = generate_root
- @cert = generate_cert(root_ca: @root_ca)
+ @intermediate_ca = generate_intermediate(signer_ca: @root_ca)
+ @cert = generate_cert(signer_ca: @intermediate_ca)
end
let(:root_certificate) do
Gitlab::Email::Smime::Certificate.new(@root_ca[:key], @root_ca[:cert])
end
+ let(:intermediate_certificate) do
+ Gitlab::Email::Smime::Certificate.new(@intermediate_ca[:key], @intermediate_ca[:cert])
+ end
+
let(:certificate) do
- Gitlab::Email::Smime::Certificate.new(@cert[:key], @cert[:cert])
+ Gitlab::Email::Smime::Certificate.new(@cert[:key], @cert[:cert], [intermediate_certificate.cert])
end
let(:mail_body) { "signed hello with Unicode €áø and\r\n newlines\r\n" }
@@ -48,17 +53,19 @@ describe Gitlab::Email::Hook::SmimeSignatureInterceptor do
# verify signature and obtain pkcs7 encoded content
p7enc = Gitlab::Email::Smime::Signer.verify_signature(
- cert: certificate.cert,
- ca_cert: root_certificate.cert,
+ ca_certs: root_certificate.cert,
signed_data: mail.encoded)
+ expect(p7enc).not_to be_nil
+
# re-verify signature from a new Mail object content
# See https://gitlab.com/gitlab-org/gitlab/issues/197386
- Gitlab::Email::Smime::Signer.verify_signature(
- cert: certificate.cert,
- ca_cert: root_certificate.cert,
+ p7_re_enc = Gitlab::Email::Smime::Signer.verify_signature(
+ ca_certs: root_certificate.cert,
signed_data: Mail.new(mail).encoded)
+ expect(p7_re_enc).not_to be_nil
+
# envelope in a Mail object and obtain the body
decoded_mail = Mail.new(p7enc.data)
diff --git a/spec/lib/gitlab/email/smime/certificate_spec.rb b/spec/lib/gitlab/email/smime/certificate_spec.rb
index 90b27602413..07b8c1e4de1 100644
--- a/spec/lib/gitlab/email/smime/certificate_spec.rb
+++ b/spec/lib/gitlab/email/smime/certificate_spec.rb
@@ -9,7 +9,8 @@ describe Gitlab::Email::Smime::Certificate do
# so we share them as instance variables in all tests
before :context do
@root_ca = generate_root
- @cert = generate_cert(root_ca: @root_ca)
+ @intermediate_ca = generate_intermediate(signer_ca: @root_ca)
+ @cert = generate_cert(signer_ca: @intermediate_ca)
end
describe 'testing environment setup' do
@@ -21,11 +22,23 @@ describe Gitlab::Email::Smime::Certificate do
end
end
+ describe 'generate_intermediate' do
+ subject { @intermediate_ca }
+
+ it 'generates an intermediate CA that expires a long way in the future' do
+ expect(subject[:cert].not_after).to be > 999.years.from_now
+ end
+
+ it 'generates an intermediate CA properly signed by the root CA' do
+ expect(subject[:cert].issuer).to eq(@root_ca[:cert].subject)
+ end
+ end
+
describe 'generate_cert' do
subject { @cert }
- it 'generates a cert properly signed by the root CA' do
- expect(subject[:cert].issuer).to eq(@root_ca[:cert].subject)
+ it 'generates a cert properly signed by the intermediate CA' do
+ expect(subject[:cert].issuer).to eq(@intermediate_ca[:cert].subject)
end
it 'generates a cert that expires soon' do
@@ -37,7 +50,7 @@ describe Gitlab::Email::Smime::Certificate do
end
context 'passing in INFINITE_EXPIRY' do
- subject { generate_cert(root_ca: @root_ca, expires_in: SmimeHelper::INFINITE_EXPIRY) }
+ subject { generate_cert(signer_ca: @intermediate_ca, expires_in: SmimeHelper::INFINITE_EXPIRY) }
it 'generates a cert that expires a long way in the future' do
expect(subject[:cert].not_after).to be > 999.years.from_now
@@ -50,7 +63,7 @@ describe Gitlab::Email::Smime::Certificate do
it 'parses correctly a certificate and key' do
parsed_cert = described_class.from_strings(@cert[:key].to_s, @cert[:cert].to_pem)
- common_cert_tests(parsed_cert, @cert, @root_ca)
+ common_cert_tests(parsed_cert, @cert, @intermediate_ca)
end
end
@@ -61,17 +74,43 @@ describe Gitlab::Email::Smime::Certificate do
parsed_cert = described_class.from_files('a_key', 'a_cert')
- common_cert_tests(parsed_cert, @cert, @root_ca)
+ common_cert_tests(parsed_cert, @cert, @intermediate_ca)
+ end
+
+ context 'with optional ca_certs' do
+ it 'parses correctly certificate, key and ca_certs' do
+ allow(File).to receive(:read).with('a_key').and_return(@cert[:key].to_s)
+ allow(File).to receive(:read).with('a_cert').and_return(@cert[:cert].to_pem)
+ allow(File).to receive(:read).with('a_ca_cert').and_return(@intermediate_ca[:cert].to_pem)
+
+ parsed_cert = described_class.from_files('a_key', 'a_cert', 'a_ca_cert')
+
+ common_cert_tests(parsed_cert, @cert, @intermediate_ca, with_ca_certs: [@intermediate_ca[:cert]])
+ end
+ end
+ end
+
+ context 'with no intermediate CA' do
+ it 'parses correctly a certificate and key' do
+ cert = generate_cert(signer_ca: @root_ca)
+
+ allow(File).to receive(:read).with('a_key').and_return(cert[:key].to_s)
+ allow(File).to receive(:read).with('a_cert').and_return(cert[:cert].to_pem)
+
+ parsed_cert = described_class.from_files('a_key', 'a_cert')
+
+ common_cert_tests(parsed_cert, cert, @root_ca)
end
end
- def common_cert_tests(parsed_cert, cert, root_ca)
+ def common_cert_tests(parsed_cert, cert, signer_ca, with_ca_certs: nil)
expect(parsed_cert.cert).to be_a(OpenSSL::X509::Certificate)
expect(parsed_cert.cert.subject).to eq(cert[:cert].subject)
- expect(parsed_cert.cert.issuer).to eq(root_ca[:cert].subject)
+ expect(parsed_cert.cert.issuer).to eq(signer_ca[:cert].subject)
expect(parsed_cert.cert.not_before).to eq(cert[:cert].not_before)
expect(parsed_cert.cert.not_after).to eq(cert[:cert].not_after)
expect(parsed_cert.cert.extensions).to include(an_object_having_attributes(oid: 'extendedKeyUsage', value: match('E-mail Protection')))
expect(parsed_cert.key).to be_a(OpenSSL::PKey::RSA)
+ expect(parsed_cert.ca_certs).to match_array(Array.wrap(with_ca_certs)) if with_ca_certs
end
end
diff --git a/spec/lib/gitlab/email/smime/signer_spec.rb b/spec/lib/gitlab/email/smime/signer_spec.rb
index 56048b7148c..d891b86da08 100644
--- a/spec/lib/gitlab/email/smime/signer_spec.rb
+++ b/spec/lib/gitlab/email/smime/signer_spec.rb
@@ -5,22 +5,39 @@ require 'spec_helper'
describe Gitlab::Email::Smime::Signer do
include SmimeHelper
- it 'signs data appropriately with SMIME' do
- root_certificate = generate_root
- certificate = generate_cert(root_ca: root_certificate)
+ let_it_be(:root_ca) { generate_root }
+ let_it_be(:intermediate_ca) { generate_intermediate(signer_ca: root_ca) }
+ context 'when using an intermediate CA' do
+ it 'signs data appropriately with SMIME' do
+ cert = generate_cert(signer_ca: intermediate_ca)
+
+ sign_and_verify('signed content', cert[:cert], cert[:key], root_ca[:cert], ca_certs: intermediate_ca[:cert])
+ end
+ end
+
+ context 'when not using an intermediate CA' do
+ it 'signs data appropriately with SMIME' do
+ cert = generate_cert(signer_ca: root_ca)
+
+ sign_and_verify('signed content', cert[:cert], cert[:key], root_ca[:cert])
+ end
+ end
+
+ def sign_and_verify(data, cert, key, root_ca_cert, ca_certs: nil)
signed_content = described_class.sign(
- cert: certificate[:cert],
- key: certificate[:key],
- data: 'signed content')
+ cert: cert,
+ key: key,
+ ca_certs: ca_certs,
+ data: data)
+
expect(signed_content).not_to be_nil
p7enc = described_class.verify_signature(
- cert: certificate[:cert],
- ca_cert: root_certificate[:cert],
+ ca_certs: root_ca_cert,
signed_data: signed_content)
expect(p7enc).not_to be_nil
- expect(p7enc.data).to eq('signed content')
+ expect(p7enc.data).to eq(data)
end
end
diff --git a/spec/lib/gitlab/exclusive_lease_helpers/sleeping_lock_spec.rb b/spec/lib/gitlab/exclusive_lease_helpers/sleeping_lock_spec.rb
new file mode 100644
index 00000000000..8917eeec56f
--- /dev/null
+++ b/spec/lib/gitlab/exclusive_lease_helpers/sleeping_lock_spec.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::ExclusiveLeaseHelpers::SleepingLock, :clean_gitlab_redis_shared_state do
+ include ::ExclusiveLeaseHelpers
+
+ let(:timeout) { 1.second }
+ let(:delay) { 0.1.seconds }
+ let(:key) { SecureRandom.hex(10) }
+
+ subject { described_class.new(key, timeout: timeout, delay: delay) }
+
+ describe '#retried?' do
+ before do
+ stub_exclusive_lease(key, 'uuid')
+ end
+
+ context 'we have not made any attempts' do
+ it { is_expected.not_to be_retried }
+ end
+
+ context 'we just made a single (initial) attempt' do
+ it 'is not considered a retry' do
+ subject.send(:try_obtain)
+
+ is_expected.not_to be_retried
+ end
+ end
+
+ context 'made multiple attempts' do
+ it 'is considered a retry' do
+ 2.times { subject.send(:try_obtain) }
+
+ is_expected.to be_retried
+ end
+ end
+ end
+
+ describe '#obtain' do
+ context 'when the lease is not held' do
+ before do
+ stub_exclusive_lease(key, 'uuid')
+ end
+
+ it 'obtains the lease on the first attempt, without sleeping' do
+ expect(subject).not_to receive(:sleep)
+
+ subject.obtain(10)
+
+ expect(subject).not_to be_retried
+ end
+ end
+
+ context 'when the lease is held elsewhere' do
+ let!(:lease) { stub_exclusive_lease_taken(key) }
+ let(:max_attempts) { 7 }
+
+ it 'retries to obtain a lease and raises an error' do
+ expect(subject).to receive(:sleep).with(delay).exactly(max_attempts - 1).times
+ expect(lease).to receive(:try_obtain).exactly(max_attempts).times
+
+ expect { subject.obtain(max_attempts) }.to raise_error('Failed to obtain a lock')
+ end
+
+ context 'when the delay is computed from the attempt number' do
+ let(:delay) { ->(n) { 3 * n } }
+
+ it 'uses the computation to determine the sleep length' do
+ expect(subject).to receive(:sleep).with(3).once
+ expect(subject).to receive(:sleep).with(6).once
+ expect(subject).to receive(:sleep).with(9).once
+ expect(lease).to receive(:try_obtain).exactly(4).times
+
+ expect { subject.obtain(4) }.to raise_error('Failed to obtain a lock')
+ end
+ end
+
+ context 'when lease is granted after retry' do
+ it 'knows that it retried' do
+ expect(subject).to receive(:sleep).with(delay).exactly(3).times
+ expect(lease).to receive(:try_obtain).exactly(3).times { nil }
+ expect(lease).to receive(:try_obtain).once { 'obtained' }
+
+ subject.obtain(max_attempts)
+
+ expect(subject).to be_retried
+ end
+ end
+ end
+
+ describe 'cancel' do
+ let!(:lease) { stub_exclusive_lease(key, 'uuid') }
+
+ it 'cancels the lease' do
+ expect(lease).to receive(:cancel)
+
+ subject.cancel
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/exclusive_lease_helpers_spec.rb b/spec/lib/gitlab/exclusive_lease_helpers_spec.rb
index 747fe369c78..9914518cda5 100644
--- a/spec/lib/gitlab/exclusive_lease_helpers_spec.rb
+++ b/spec/lib/gitlab/exclusive_lease_helpers_spec.rb
@@ -22,9 +22,7 @@ describe Gitlab::ExclusiveLeaseHelpers, :clean_gitlab_redis_shared_state do
end
context 'when the lease is not obtained yet' do
- before do
- stub_exclusive_lease(unique_key, 'uuid')
- end
+ let!(:lease) { stub_exclusive_lease(unique_key, 'uuid') }
it 'calls the given block' do
expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_with_args(false)
@@ -37,7 +35,7 @@ describe Gitlab::ExclusiveLeaseHelpers, :clean_gitlab_redis_shared_state do
end
it 'cancels the exclusive lease after the block' do
- expect_to_cancel_exclusive_lease(unique_key, 'uuid')
+ expect(lease).to receive(:cancel).once
subject
end
@@ -81,11 +79,32 @@ describe Gitlab::ExclusiveLeaseHelpers, :clean_gitlab_redis_shared_state do
end
end
+ context 'when we specify no retries' do
+ let(:options) { { retries: 0 } }
+
+ it 'never sleeps' do
+ expect(class_instance).not_to receive(:sleep)
+
+ expect { subject }.to raise_error('Failed to obtain a lock')
+ end
+ end
+
context 'when sleep second is specified' do
- let(:options) { { retries: 0, sleep_sec: 0.05.seconds } }
+ let(:options) { { retries: 1, sleep_sec: 0.05.seconds } }
+
+ it 'receives the specified argument' do
+ expect_any_instance_of(Object).to receive(:sleep).with(0.05.seconds).once
+
+ expect { subject }.to raise_error('Failed to obtain a lock')
+ end
+ end
+
+ context 'when sleep second is specified as a lambda' do
+ let(:options) { { retries: 2, sleep_sec: ->(num) { 0.1 + num } } }
it 'receives the specified argument' do
- expect(class_instance).to receive(:sleep).with(0.05.seconds).once
+ expect_any_instance_of(Object).to receive(:sleep).with(1.1.seconds).once
+ expect_any_instance_of(Object).to receive(:sleep).with(2.1.seconds).once
expect { subject }.to raise_error('Failed to obtain a lock')
end
diff --git a/spec/lib/gitlab/exclusive_lease_spec.rb b/spec/lib/gitlab/exclusive_lease_spec.rb
index 0739f622af5..2c0bb23a0b6 100644
--- a/spec/lib/gitlab/exclusive_lease_spec.rb
+++ b/spec/lib/gitlab/exclusive_lease_spec.rb
@@ -21,6 +21,27 @@ describe Gitlab::ExclusiveLease, :clean_gitlab_redis_shared_state do
end
end
+ describe '.redis_shared_state_key' do
+ it 'provides a namespaced key' do
+ expect(described_class.redis_shared_state_key(unique_key))
+ .to start_with(described_class::PREFIX)
+ .and include(unique_key)
+ end
+ end
+
+ describe '.ensure_prefixed_key' do
+ it 'does not double prefix a key' do
+ prefixed = described_class.redis_shared_state_key(unique_key)
+
+ expect(described_class.ensure_prefixed_key(unique_key))
+ .to eq(described_class.ensure_prefixed_key(prefixed))
+ end
+
+ it 'raises errors when there is no key' do
+ expect { described_class.ensure_prefixed_key(nil) }.to raise_error(described_class::NoKey)
+ end
+ end
+
describe '#renew' do
it 'returns true when we have the existing lease' do
lease = described_class.new(unique_key, timeout: 3600)
@@ -61,18 +82,61 @@ describe Gitlab::ExclusiveLease, :clean_gitlab_redis_shared_state do
end
end
- describe '.cancel' do
- it 'can cancel a lease' do
- uuid = new_lease(unique_key)
- expect(uuid).to be_present
- expect(new_lease(unique_key)).to eq(false)
+ describe 'cancellation' do
+ def new_lease(key)
+ described_class.new(key, timeout: 3600)
+ end
- described_class.cancel(unique_key, uuid)
- expect(new_lease(unique_key)).to be_present
+ shared_examples 'cancelling a lease' do
+ let(:lease) { new_lease(unique_key) }
+
+ it 'releases the held lease' do
+ uuid = lease.try_obtain
+ expect(uuid).to be_present
+ expect(new_lease(unique_key).try_obtain).to eq(false)
+
+ cancel_lease(uuid)
+
+ expect(new_lease(unique_key).try_obtain).to be_present
+ end
end
- def new_lease(key)
- described_class.new(key, timeout: 3600).try_obtain
+ describe '.cancel' do
+ def cancel_lease(uuid)
+ described_class.cancel(release_key, uuid)
+ end
+
+ context 'when called with the unprefixed key' do
+ it_behaves_like 'cancelling a lease' do
+ let(:release_key) { unique_key }
+ end
+ end
+
+ context 'when called with the prefixed key' do
+ it_behaves_like 'cancelling a lease' do
+ let(:release_key) { described_class.redis_shared_state_key(unique_key) }
+ end
+ end
+
+ it 'does not raise errors when given a nil key' do
+ expect { described_class.cancel(nil, nil) }.not_to raise_error
+ end
+ end
+
+ describe '#cancel' do
+ def cancel_lease(_uuid)
+ lease.cancel
+ end
+
+ it_behaves_like 'cancelling a lease'
+
+ it 'is safe to call even if the lease was never obtained' do
+ lease = new_lease(unique_key)
+
+ lease.cancel
+
+ expect(new_lease(unique_key).try_obtain).to be_present
+ end
end
end
diff --git a/spec/lib/gitlab/experimentation_spec.rb b/spec/lib/gitlab/experimentation_spec.rb
index a39c50ab038..99442cb0ca6 100644
--- a/spec/lib/gitlab/experimentation_spec.rb
+++ b/spec/lib/gitlab/experimentation_spec.rb
@@ -6,19 +6,16 @@ describe Gitlab::Experimentation do
before do
stub_const('Gitlab::Experimentation::EXPERIMENTS', {
test_experiment: {
- feature_toggle: feature_toggle,
environment: environment,
- enabled_ratio: enabled_ratio,
tracking_category: 'Team'
}
})
- stub_feature_flags(feature_toggle => true)
+ allow(Feature).to receive(:get).with(:test_experiment_experiment_percentage).and_return double(percentage_of_time_value: enabled_percentage)
end
- let(:feature_toggle) { :test_experiment_toggle }
let(:environment) { Rails.env.test? }
- let(:enabled_ratio) { 0.1 }
+ let(:enabled_percentage) { 10 }
describe Gitlab::Experimentation::ControllerConcern, type: :controller do
controller(ApplicationController) do
@@ -251,44 +248,16 @@ describe Gitlab::Experimentation do
end
end
- describe 'feature toggle' do
- context 'feature toggle is not set' do
- let(:feature_toggle) { nil }
+ describe 'experiment is disabled' do
+ let(:enabled_percentage) { 0 }
- it { is_expected.to be_truthy }
- end
-
- context 'feature toggle is not set, but a feature with the experiment key as name does exist' do
- before do
- stub_feature_flags(test_experiment: false)
- end
-
- let(:feature_toggle) { nil }
-
- it { is_expected.to be_falsey }
- end
-
- context 'feature toggle is disabled' do
- before do
- stub_feature_flags(feature_toggle => false)
- end
-
- it { is_expected.to be_falsey }
- end
+ it { is_expected.to be_falsey }
end
- describe 'environment' do
- context 'environment is not set' do
- let(:environment) { nil }
-
- it { is_expected.to be_truthy }
- end
-
- context 'we are on the wrong environment' do
- let(:environment) { ::Gitlab.com? }
+ describe 'we are on the wrong environment' do
+ let(:environment) { ::Gitlab.com? }
- it { is_expected.to be_falsey }
- end
+ it { is_expected.to be_falsey }
end
end
@@ -312,12 +281,6 @@ describe Gitlab::Experimentation do
it { is_expected.to be_truthy }
- context 'enabled ratio is not set' do
- let(:enabled_ratio) { nil }
-
- it { is_expected.to be_falsey }
- end
-
describe 'experimentation_subject_index' do
context 'experimentation_subject_index is not set' do
let(:experimentation_subject_index) { nil }
diff --git a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb
index 084dde1f93f..335135696ef 100644
--- a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb
+++ b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb
@@ -147,6 +147,18 @@ describe Gitlab::Gfm::ReferenceRewriter do
it { is_expected.to eq text }
end
+ context 'when referring to a group' do
+ let(:text) { "group @#{group.full_path}" }
+
+ it { is_expected.to eq text }
+ end
+
+ context 'when referring to a user' do
+ let(:text) { "user @#{user.full_path}" }
+
+ it { is_expected.to eq text }
+ end
+
context 'when referable has a nil reference' do
before do
create(:milestone, title: '9.0', project: old_project)
diff --git a/spec/lib/gitlab/git/attributes_parser_spec.rb b/spec/lib/gitlab/git/attributes_parser_spec.rb
index 94b7a086e59..45db4acd3ac 100644
--- a/spec/lib/gitlab/git/attributes_parser_spec.rb
+++ b/spec/lib/gitlab/git/attributes_parser_spec.rb
@@ -75,6 +75,14 @@ describe Gitlab::Git::AttributesParser, :seed_helper do
expect(subject.attributes('test.foo')).to eq({})
end
end
+
+ context 'when attributes data has binary data' do
+ let(:data) { "\xFF\xFE*\u0000.\u0000c\u0000s".b }
+
+ it 'returns an empty Hash' do
+ expect(subject.attributes('test.foo')).to eq({})
+ end
+ end
end
describe '#patterns' do
diff --git a/spec/lib/gitlab/git/blob_spec.rb b/spec/lib/gitlab/git/blob_spec.rb
index 06f9767d58b..46d9b78c14b 100644
--- a/spec/lib/gitlab/git/blob_spec.rb
+++ b/spec/lib/gitlab/git/blob_spec.rb
@@ -652,4 +652,16 @@ describe Gitlab::Git::Blob, :seed_helper do
expect(described_class).to respond_to(:gitlab_blob_size)
end
end
+
+ describe '#lines' do
+ context 'when the encoding cannot be detected' do
+ it 'successfully splits the data' do
+ data = "test\nblob"
+ blob = Gitlab::Git::Blob.new(name: 'test', size: data.bytesize, data: data)
+ expect(blob).to receive(:ruby_encoding) { nil }
+
+ expect(blob.lines).to eq(data.split("\n"))
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb
index c2fc228d34a..edd367673fb 100644
--- a/spec/lib/gitlab/git/commit_spec.rb
+++ b/spec/lib/gitlab/git/commit_spec.rb
@@ -161,6 +161,26 @@ describe Gitlab::Git::Commit, :seed_helper do
expect(described_class.find(repository, "+123_4532530XYZ")).to be_nil
end
+ it "returns nil for id started with dash" do
+ expect(described_class.find(repository, "-HEAD")).to be_nil
+ end
+
+ it "returns nil for id containing colon" do
+ expect(described_class.find(repository, "HEAD:")).to be_nil
+ end
+
+ it "returns nil for id containing space" do
+ expect(described_class.find(repository, "HE AD")).to be_nil
+ end
+
+ it "returns nil for id containing tab" do
+ expect(described_class.find(repository, "HE\tAD")).to be_nil
+ end
+
+ it "returns nil for id containing NULL" do
+ expect(described_class.find(repository, "HE\x00AD")).to be_nil
+ end
+
context 'with broken repo' do
let(:repository) { Gitlab::Git::Repository.new('default', TEST_BROKEN_REPO_PATH, '', 'group/project') }
diff --git a/spec/lib/gitlab/git/tag_spec.rb b/spec/lib/gitlab/git/tag_spec.rb
index 87db3f588ad..6d3b239c38f 100644
--- a/spec/lib/gitlab/git/tag_spec.rb
+++ b/spec/lib/gitlab/git/tag_spec.rb
@@ -13,6 +13,13 @@ describe Gitlab::Git::Tag, :seed_helper do
it { expect(tag.target).to eq("f4e6814c3e4e7a0de82a9e7cd20c626cc963a2f8") }
it { expect(tag.dereferenced_target.sha).to eq("6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9") }
it { expect(tag.message).to eq("Release") }
+ it { expect(tag.has_signature?).to be_falsey }
+ it { expect(tag.signature_type).to eq(:NONE) }
+ it { expect(tag.signature).to be_nil }
+ it { expect(tag.tagger.name).to eq("Dmitriy Zaporozhets") }
+ it { expect(tag.tagger.email).to eq("dmitriy.zaporozhets@gmail.com") }
+ it { expect(tag.tagger.date).to eq(Google::Protobuf::Timestamp.new(seconds: 1393491299)) }
+ it { expect(tag.tagger.timezone).to eq("+0200") }
end
describe 'last tag' do
@@ -22,6 +29,29 @@ describe Gitlab::Git::Tag, :seed_helper do
it { expect(tag.target).to eq("2ac1f24e253e08135507d0830508febaaccf02ee") }
it { expect(tag.dereferenced_target.sha).to eq("fa1b1e6c004a68b7d8763b86455da9e6b23e36d6") }
it { expect(tag.message).to eq("Version 1.2.1") }
+ it { expect(tag.has_signature?).to be_falsey }
+ it { expect(tag.signature_type).to eq(:NONE) }
+ it { expect(tag.signature).to be_nil }
+ it { expect(tag.tagger.name).to eq("Douwe Maan") }
+ it { expect(tag.tagger.email).to eq("douwe@selenight.nl") }
+ it { expect(tag.tagger.date).to eq(Google::Protobuf::Timestamp.new(seconds: 1427789449)) }
+ it { expect(tag.tagger.timezone).to eq("+0200") }
+ end
+
+ describe 'signed tag' do
+ let(:project) { create(:project, :repository) }
+ let(:tag) { project.repository.find_tag('v1.1.1') }
+
+ it { expect(tag.target).to eq("8f03acbcd11c53d9c9468078f32a2622005a4841") }
+ it { expect(tag.dereferenced_target.sha).to eq("189a6c924013fc3fe40d6f1ec1dc20214183bc97") }
+ it { expect(tag.message).to eq("x509 signed tag" + "\n" + X509Helpers::User1.signed_tag_signature.chomp) }
+ it { expect(tag.has_signature?).to be_truthy }
+ it { expect(tag.signature_type).to eq(:X509) }
+ it { expect(tag.signature).not_to be_nil }
+ it { expect(tag.tagger.name).to eq("Roger Meier") }
+ it { expect(tag.tagger.email).to eq("r.meier@siemens.com") }
+ it { expect(tag.tagger.date).to eq(Google::Protobuf::Timestamp.new(seconds: 1574261780)) }
+ it { expect(tag.tagger.timezone).to eq("+0100") }
end
it { expect(repository.tags.size).to eq(SeedRepo::Repo::TAGS.size) }
diff --git a/spec/lib/gitlab/git_access_design_spec.rb b/spec/lib/gitlab/git_access_design_spec.rb
new file mode 100644
index 00000000000..d816608f7e5
--- /dev/null
+++ b/spec/lib/gitlab/git_access_design_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe Gitlab::GitAccessDesign do
+ include DesignManagementTestHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { project.owner }
+ let(:protocol) { 'web' }
+ let(:actor) { user }
+
+ subject(:access) do
+ described_class.new(actor, project, protocol, authentication_abilities: [:read_project, :download_code, :push_code])
+ end
+
+ describe '#check' do
+ subject { access.check('git-receive-pack', ::Gitlab::GitAccess::ANY) }
+
+ before do
+ enable_design_management
+ end
+
+ context 'when the user is allowed to manage designs' do
+ it do
+ is_expected.to be_a(::Gitlab::GitAccessResult::Success)
+ end
+ end
+
+ context 'when the user is not allowed to manage designs' do
+ let_it_be(:user) { create(:user) }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(::Gitlab::GitAccess::ForbiddenError)
+ end
+ end
+
+ context 'when the protocol is not web' do
+ let(:protocol) { 'https' }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(::Gitlab::GitAccess::ForbiddenError)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git_access_snippet_spec.rb b/spec/lib/gitlab/git_access_snippet_spec.rb
index bbc3808df12..48b425a8ec5 100644
--- a/spec/lib/gitlab/git_access_snippet_spec.rb
+++ b/spec/lib/gitlab/git_access_snippet_spec.rb
@@ -11,8 +11,9 @@ describe Gitlab::GitAccessSnippet do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:snippet) { create(:project_snippet, :public, :repository, project: project) }
- let(:repository) { snippet.repository }
+ let_it_be(:migration_bot) { User.migration_bot }
+ let(:repository) { snippet.repository }
let(:actor) { user }
let(:protocol) { 'ssh' }
let(:changes) { Gitlab::GitAccess::ANY }
@@ -27,20 +28,19 @@ describe Gitlab::GitAccessSnippet do
let(:actor) { build(:deploy_key) }
it 'does not allow push and pull access' do
+ expect { push_access_check }.to raise_forbidden(described_class::ERROR_MESSAGES[:authentication_mechanism])
expect { pull_access_check }.to raise_forbidden(described_class::ERROR_MESSAGES[:authentication_mechanism])
end
end
- describe 'when feature flag :version_snippets is disabled' do
- let(:user) { snippet.author }
-
- before do
- stub_feature_flags(version_snippets: false)
- end
+ shared_examples 'actor is migration bot' do
+ context 'when user is the migration bot' do
+ let(:user) { migration_bot }
- it 'allows push and pull access' do
- expect { pull_access_check }.not_to raise_error
- expect { push_access_check }.not_to raise_error
+ it 'can perform git operations' do
+ expect { push_access_check }.not_to raise_error
+ expect { pull_access_check }.not_to raise_error
+ end
end
end
@@ -90,6 +90,12 @@ describe Gitlab::GitAccessSnippet do
expect { push_access_check }.not_to raise_error
expect { pull_access_check }.not_to raise_error
end
+
+ it_behaves_like 'actor is migration bot' do
+ before do
+ expect(migration_bot.required_terms_not_accepted?).to be_truthy
+ end
+ end
end
context 'project snippet accessibility', :aggregate_failures do
@@ -120,6 +126,7 @@ describe Gitlab::GitAccessSnippet do
context 'when project is public' do
it_behaves_like 'checks accessibility'
+ it_behaves_like 'actor is migration bot'
end
context 'when project is public but snippet feature is private' do
@@ -130,6 +137,7 @@ describe Gitlab::GitAccessSnippet do
end
it_behaves_like 'checks accessibility'
+ it_behaves_like 'actor is migration bot'
end
context 'when project is not accessible' do
@@ -140,11 +148,58 @@ describe Gitlab::GitAccessSnippet do
let(:membership) { membership }
it 'respects accessibility' do
- expect { push_access_check }.to raise_error(described_class::NotFoundError)
- expect { pull_access_check }.to raise_error(described_class::NotFoundError)
+ expect { push_access_check }.to raise_snippet_not_found
+ expect { pull_access_check }.to raise_snippet_not_found
+ end
+ end
+ end
+
+ it_behaves_like 'actor is migration bot'
+ end
+
+ context 'when project is archived' do
+ let(:project) { create(:project, :public, :archived) }
+
+ [:anonymous, :non_member].each do |membership|
+ context membership.to_s do
+ let(:membership) { membership }
+
+ it 'cannot perform git operations' do
+ expect { push_access_check }.to raise_error(described_class::ForbiddenError)
+ expect { pull_access_check }.to raise_error(described_class::ForbiddenError)
+ end
+ end
+ end
+
+ [:guest, :reporter, :maintainer, :author, :admin].each do |membership|
+ context membership.to_s do
+ let(:membership) { membership }
+
+ it 'cannot perform git pushes' do
+ expect { push_access_check }.to raise_error(described_class::ForbiddenError)
+ expect { pull_access_check }.not_to raise_error
+ end
+ end
+ end
+
+ it_behaves_like 'actor is migration bot'
+ end
+
+ context 'when snippet feature is disabled' do
+ let(:project) { create(:project, :public, :snippets_disabled) }
+
+ [:anonymous, :non_member, :author, :admin].each do |membership|
+ context membership.to_s do
+ let(:membership) { membership }
+
+ it 'cannot perform git operations' do
+ expect { push_access_check }.to raise_error(described_class::ForbiddenError)
+ expect { pull_access_check }.to raise_error(described_class::ForbiddenError)
end
end
end
+
+ it_behaves_like 'actor is migration bot'
end
end
@@ -172,6 +227,8 @@ describe Gitlab::GitAccessSnippet do
expect { pull_access_check }.to raise_error(error_class)
end
end
+
+ it_behaves_like 'actor is migration bot'
end
end
@@ -179,36 +236,66 @@ describe Gitlab::GitAccessSnippet do
let(:user) { snippet.author }
let!(:primary_node) { FactoryBot.create(:geo_node, :primary) }
- # Without override, push access would return Gitlab::GitAccessResult::CustomAction
- it 'skips geo for snippet' do
+ before do
allow(::Gitlab::Database).to receive(:read_only?).and_return(true)
allow(::Gitlab::Geo).to receive(:secondary_with_primary?).and_return(true)
+ end
+ # Without override, push access would return Gitlab::GitAccessResult::CustomAction
+ it 'skips geo for snippet' do
expect { push_access_check }.to raise_forbidden(/You can't push code to a read-only GitLab instance/)
end
+
+ context 'when user is migration bot' do
+ let(:user) { migration_bot }
+
+ it 'skips geo for snippet' do
+ expect { push_access_check }.to raise_forbidden(/You can't push code to a read-only GitLab instance/)
+ end
+ end
end
context 'when changes are specific' do
let(:changes) { "2d1db523e11e777e49377cfb22d368deec3f0793 ddd0f15ae83993f5cb66a927a28673882e99100b master" }
let(:user) { snippet.author }
- it 'does not raise error if SnippetCheck does not raise error' do
- expect_next_instance_of(Gitlab::Checks::SnippetCheck) do |check|
- expect(check).to receive(:validate!).and_call_original
+ shared_examples 'snippet checks' do
+ it 'does not raise error if SnippetCheck does not raise error' do
+ expect_next_instance_of(Gitlab::Checks::SnippetCheck) do |check|
+ expect(check).to receive(:validate!).and_call_original
+ end
+ expect_next_instance_of(Gitlab::Checks::PushFileCountCheck) do |check|
+ expect(check).to receive(:validate!)
+ end
+
+ expect { push_access_check }.not_to raise_error
end
- expect_next_instance_of(Gitlab::Checks::PushFileCountCheck) do |check|
- expect(check).to receive(:validate!)
+
+ it 'raises error if SnippetCheck raises error' do
+ expect_next_instance_of(Gitlab::Checks::SnippetCheck) do |check|
+ allow(check).to receive(:validate!).and_raise(Gitlab::GitAccess::ForbiddenError, 'foo')
+ end
+
+ expect { push_access_check }.to raise_forbidden('foo')
end
- expect { push_access_check }.not_to raise_error
- end
+ it 'sets the file count limit from Snippet class' do
+ service = double
- it 'raises error if SnippetCheck raises error' do
- expect_next_instance_of(Gitlab::Checks::SnippetCheck) do |check|
- allow(check).to receive(:validate!).and_raise(Gitlab::GitAccess::ForbiddenError, 'foo')
+ expect(service).to receive(:validate!).and_return(nil)
+ expect(Snippet).to receive(:max_file_limit).with(user).and_return(5)
+ expect(Gitlab::Checks::PushFileCountCheck).to receive(:new).with(anything, hash_including(limit: 5)).and_return(service)
+
+ push_access_check
end
+ end
+
+ it_behaves_like 'snippet checks'
- expect { push_access_check }.to raise_forbidden('foo')
+ context 'when user is migration bot' do
+ let(:user) { migration_bot }
+
+ it_behaves_like 'snippet checks'
end
end
@@ -221,6 +308,16 @@ describe Gitlab::GitAccessSnippet do
let(:ref) { "refs/heads/snippet/edit-file" }
let(:changes) { "#{oldrev} #{newrev} #{ref}" }
+ shared_examples 'migration bot does not err' do
+ let(:actor) { migration_bot }
+
+ it 'does not err' do
+ expect(snippet.repository_size_checker).not_to receive(:above_size_limit?)
+
+ expect { push_access_check }.not_to raise_error
+ end
+ end
+
shared_examples_for 'a push to repository already over the limit' do
it 'errs' do
expect(snippet.repository_size_checker).to receive(:above_size_limit?).and_return(true)
@@ -229,6 +326,8 @@ describe Gitlab::GitAccessSnippet do
push_access_check
end.to raise_error(described_class::ForbiddenError, /Your push has been rejected/)
end
+
+ it_behaves_like 'migration bot does not err'
end
shared_examples_for 'a push to repository below the limit' do
@@ -241,6 +340,8 @@ describe Gitlab::GitAccessSnippet do
expect { push_access_check }.not_to raise_error
end
+
+ it_behaves_like 'migration bot does not err'
end
shared_examples_for 'a push to repository to make it over the limit' do
@@ -255,6 +356,8 @@ describe Gitlab::GitAccessSnippet do
push_access_check
end.to raise_error(described_class::ForbiddenError, /Your push to this repository would cause it to exceed the size limit/)
end
+
+ it_behaves_like 'migration bot does not err'
end
context 'when GIT_OBJECT_DIRECTORY_RELATIVE env var is set' do
diff --git a/spec/lib/gitlab/git_access_wiki_spec.rb b/spec/lib/gitlab/git_access_wiki_spec.rb
index b5e673c9e79..e42570804a8 100644
--- a/spec/lib/gitlab/git_access_wiki_spec.rb
+++ b/spec/lib/gitlab/git_access_wiki_spec.rb
@@ -52,14 +52,10 @@ describe Gitlab::GitAccessWiki do
end
context 'when the wiki repository does not exist' do
- it 'returns not found' do
- wiki_repo = project.wiki.repository
- Gitlab::GitalyClient::StorageSettings.allow_disk_access do
- FileUtils.rm_rf(wiki_repo.path)
- end
+ let(:project) { create(:project) }
- # Sanity check for rm_rf
- expect(wiki_repo.exists?).to eq(false)
+ it 'returns not found' do
+ expect(project.wiki_repository_exists?).to eq(false)
expect { subject }.to raise_error(Gitlab::GitAccess::NotFoundError, 'A repository for this project does not exist yet.')
end
diff --git a/spec/lib/gitlab/gl_repository/repo_type_spec.rb b/spec/lib/gitlab/gl_repository/repo_type_spec.rb
index 6185b068d4c..bf6df55b71e 100644
--- a/spec/lib/gitlab/gl_repository/repo_type_spec.rb
+++ b/spec/lib/gitlab/gl_repository/repo_type_spec.rb
@@ -7,6 +7,7 @@ describe Gitlab::GlRepository::RepoType do
let_it_be(:project_snippet) { create(:project_snippet, project: project, author: project.owner) }
let(:project_path) { project.repository.full_path }
let(:wiki_path) { project.wiki.repository.full_path }
+ let(:design_path) { project.design_repository.full_path }
let(:personal_snippet_path) { "snippets/#{personal_snippet.id}" }
let(:project_snippet_path) { "#{project.full_path}/snippets/#{project_snippet.id}" }
@@ -24,6 +25,7 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class).not_to be_wiki
expect(described_class).to be_project
expect(described_class).not_to be_snippet
+ expect(described_class).not_to be_design
end
end
@@ -33,6 +35,7 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class.valid?(wiki_path)).to be_truthy
expect(described_class.valid?(personal_snippet_path)).to be_truthy
expect(described_class.valid?(project_snippet_path)).to be_truthy
+ expect(described_class.valid?(design_path)).to be_truthy
end
end
end
@@ -51,6 +54,7 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class).to be_wiki
expect(described_class).not_to be_project
expect(described_class).not_to be_snippet
+ expect(described_class).not_to be_design
end
end
@@ -60,6 +64,7 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class.valid?(wiki_path)).to be_truthy
expect(described_class.valid?(personal_snippet_path)).to be_falsey
expect(described_class.valid?(project_snippet_path)).to be_falsey
+ expect(described_class.valid?(design_path)).to be_falsey
end
end
end
@@ -79,6 +84,7 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class).to be_snippet
expect(described_class).not_to be_wiki
expect(described_class).not_to be_project
+ expect(described_class).not_to be_design
end
end
@@ -88,6 +94,7 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class.valid?(wiki_path)).to be_falsey
expect(described_class.valid?(personal_snippet_path)).to be_truthy
expect(described_class.valid?(project_snippet_path)).to be_truthy
+ expect(described_class.valid?(design_path)).to be_falsey
end
end
end
@@ -115,8 +122,38 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class.valid?(wiki_path)).to be_falsey
expect(described_class.valid?(personal_snippet_path)).to be_truthy
expect(described_class.valid?(project_snippet_path)).to be_truthy
+ expect(described_class.valid?(design_path)).to be_falsey
end
end
end
end
+
+ describe Gitlab::GlRepository::DESIGN do
+ it_behaves_like 'a repo type' do
+ let(:expected_identifier) { "design-#{project.id}" }
+ let(:expected_id) { project.id.to_s }
+ let(:expected_suffix) { '.design' }
+ let(:expected_repository) { project.design_repository }
+ let(:expected_container) { project }
+ end
+
+ it 'knows its type' do
+ aggregate_failures do
+ expect(described_class).to be_design
+ expect(described_class).not_to be_project
+ expect(described_class).not_to be_wiki
+ expect(described_class).not_to be_snippet
+ end
+ end
+
+ it 'checks if repository path is valid' do
+ aggregate_failures do
+ expect(described_class.valid?(design_path)).to be_truthy
+ expect(described_class.valid?(project_path)).to be_falsey
+ expect(described_class.valid?(wiki_path)).to be_falsey
+ expect(described_class.valid?(personal_snippet_path)).to be_falsey
+ expect(described_class.valid?(project_snippet_path)).to be_falsey
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/gl_repository_spec.rb b/spec/lib/gitlab/gl_repository_spec.rb
index 858f436047e..5f5244b7116 100644
--- a/spec/lib/gitlab/gl_repository_spec.rb
+++ b/spec/lib/gitlab/gl_repository_spec.rb
@@ -19,6 +19,10 @@ describe ::Gitlab::GlRepository do
expect(described_class.parse("snippet-#{snippet.id}")).to eq([snippet, nil, Gitlab::GlRepository::SNIPPET])
end
+ it 'parses a design gl_repository' do
+ expect(described_class.parse("design-#{project.id}")).to eq([project, project, Gitlab::GlRepository::DESIGN])
+ end
+
it 'throws an argument error on an invalid gl_repository type' do
expect { described_class.parse("badformat-#{project.id}") }.to raise_error(ArgumentError)
end
@@ -27,4 +31,15 @@ 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/google_code_import/client_spec.rb b/spec/lib/gitlab/google_code_import/client_spec.rb
index 2e929a62ebc..fb1c7085017 100644
--- a/spec/lib/gitlab/google_code_import/client_spec.rb
+++ b/spec/lib/gitlab/google_code_import/client_spec.rb
@@ -3,7 +3,7 @@
require "spec_helper"
describe Gitlab::GoogleCodeImport::Client do
- let(:raw_data) { JSON.parse(fixture_file("GoogleCodeProjectHosting.json")) }
+ let(:raw_data) { Gitlab::Json.parse(fixture_file("GoogleCodeProjectHosting.json")) }
subject { described_class.new(raw_data) }
diff --git a/spec/lib/gitlab/google_code_import/importer_spec.rb b/spec/lib/gitlab/google_code_import/importer_spec.rb
index 7055df89c09..3118671bb5e 100644
--- a/spec/lib/gitlab/google_code_import/importer_spec.rb
+++ b/spec/lib/gitlab/google_code_import/importer_spec.rb
@@ -4,7 +4,7 @@ require "spec_helper"
describe Gitlab::GoogleCodeImport::Importer do
let(:mapped_user) { create(:user, username: "thilo123") }
- let(:raw_data) { JSON.parse(fixture_file("GoogleCodeProjectHosting.json")) }
+ let(:raw_data) { Gitlab::Json.parse(fixture_file("GoogleCodeProjectHosting.json")) }
let(:client) { Gitlab::GoogleCodeImport::Client.new(raw_data) }
let(:import_data) do
{
diff --git a/spec/lib/gitlab/grape_logging/formatters/lograge_with_timestamp_spec.rb b/spec/lib/gitlab/grape_logging/formatters/lograge_with_timestamp_spec.rb
index d3b108f60ff..84f23bb2ad9 100644
--- a/spec/lib/gitlab/grape_logging/formatters/lograge_with_timestamp_spec.rb
+++ b/spec/lib/gitlab/grape_logging/formatters/lograge_with_timestamp_spec.rb
@@ -30,7 +30,7 @@ describe Gitlab::GrapeLogging::Formatters::LogrageWithTimestamp do
}
end
let(:time) { Time.now }
- let(:result) { JSON.parse(subject) }
+ let(:result) { Gitlab::Json.parse(subject) }
subject { described_class.new.call(:info, time, nil, log_entry) }
diff --git a/spec/lib/gitlab/grape_logging/loggers/cloudflare_logger_spec.rb b/spec/lib/gitlab/grape_logging/loggers/cloudflare_logger_spec.rb
new file mode 100644
index 00000000000..922a433d7ac
--- /dev/null
+++ b/spec/lib/gitlab/grape_logging/loggers/cloudflare_logger_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::GrapeLogging::Loggers::CloudflareLogger do
+ subject { described_class.new }
+
+ describe "#parameters" do
+ let(:mock_request) { ActionDispatch::Request.new({}) }
+ let(:start_time) { Time.new(2018, 01, 01) }
+
+ describe 'with no Cloudflare headers' do
+ it 'returns an empty hash' do
+ expect(subject.parameters(mock_request, nil)).to eq({})
+ end
+ end
+
+ describe 'with Cloudflare headers' do
+ before do
+ mock_request.headers['Cf-Ray'] = SecureRandom.hex
+ mock_request.headers['Cf-Request-Id'] = SecureRandom.hex
+ end
+
+ it 'returns the correct duration in seconds' do
+ data = subject.parameters(mock_request, nil)
+
+ expect(data.keys).to contain_exactly(:cf_ray, :cf_request_id)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/grape_logging/loggers/exception_logger_spec.rb b/spec/lib/gitlab/grape_logging/loggers/exception_logger_spec.rb
index c9021e2f436..cc9535d4d2c 100644
--- a/spec/lib/gitlab/grape_logging/loggers/exception_logger_spec.rb
+++ b/spec/lib/gitlab/grape_logging/loggers/exception_logger_spec.rb
@@ -3,14 +3,73 @@
require 'spec_helper'
describe Gitlab::GrapeLogging::Loggers::ExceptionLogger do
- subject { described_class.new }
-
let(:mock_request) { OpenStruct.new(env: {}) }
+ let(:response_body) { nil }
describe ".parameters" do
+ subject { described_class.new.parameters(mock_request, response_body) }
+
describe 'when no exception is available' do
it 'returns an empty hash' do
- expect(subject.parameters(mock_request, nil)).to eq({})
+ expect(subject).to eq({})
+ end
+ end
+
+ describe 'with a response' do
+ before do
+ mock_request.env[::API::Helpers::API_RESPONSE_STATUS_CODE] = code
+ end
+
+ context 'with a String response' do
+ let(:response_body) { { message: "something went wrong" }.to_json }
+ let(:code) { 400 }
+ let(:expected) { { api_error: [response_body.to_s] } }
+
+ it 'logs the response body' do
+ expect(subject).to eq(expected)
+ end
+ end
+
+ context 'with an Array response' do
+ let(:response_body) { ["hello world", 1] }
+ let(:code) { 400 }
+ let(:expected) { { api_error: ["hello world", "1"] } }
+
+ it 'casts all elements to strings' do
+ expect(subject).to eq(expected)
+ end
+ end
+
+ # Rack v2.0.9 can return a BodyProxy. This was changed in later versions:
+ # https://github.com/rack/rack/blob/2.0.9/lib/rack/response.rb#L69
+ context 'with a Rack BodyProxy response' do
+ let(:message) { { message: "something went wrong" }.to_json }
+ let(:response) { Rack::Response.new(message, code, {}) }
+ let(:response_body) { Rack::BodyProxy.new(response) }
+ let(:code) { 400 }
+ let(:expected) { { api_error: [message] } }
+
+ it 'logs the response body' do
+ expect(subject).to eq(expected)
+ end
+ end
+
+ context 'unauthorized error' do
+ let(:response_body) { 'unauthorized' }
+ let(:code) { 401 }
+
+ it 'does not log an api_error field' do
+ expect(subject).not_to have_key(:api_error)
+ end
+ end
+
+ context 'HTTP success' do
+ let(:response_body) { 'success' }
+ let(:code) { 200 }
+
+ it 'does not log an api_error field' do
+ expect(subject).not_to have_key(:api_error)
+ end
end
end
@@ -32,7 +91,7 @@ describe Gitlab::GrapeLogging::Loggers::ExceptionLogger do
end
it 'returns the correct fields' do
- expect(subject.parameters(mock_request, nil)).to eq(expected)
+ expect(subject).to eq(expected)
end
context 'with backtrace' do
@@ -43,7 +102,7 @@ describe Gitlab::GrapeLogging::Loggers::ExceptionLogger do
end
it 'includes the backtrace' do
- expect(subject.parameters(mock_request, nil)).to eq(expected)
+ expect(subject).to eq(expected)
end
end
end
diff --git a/spec/lib/gitlab/graphql/authorize/authorize_field_service_spec.rb b/spec/lib/gitlab/graphql/authorize/authorize_field_service_spec.rb
index 98659dbed57..c1dab5feb91 100644
--- a/spec/lib/gitlab/graphql/authorize/authorize_field_service_spec.rb
+++ b/spec/lib/gitlab/graphql/authorize/authorize_field_service_spec.rb
@@ -84,6 +84,16 @@ describe Gitlab::Graphql::Authorize::AuthorizeFieldService do
end
end
+ context 'when the field is a connection' do
+ context 'when it resolves to nil' do
+ let(:field) { type_with_field(Types::QueryType.connection_type, :read_field, nil).fields['testField'].to_graphql }
+
+ it 'does not fail when authorizing' do
+ expect(resolved).to be_nil
+ end
+ end
+ end
+
context 'when the field is a specific type' do
let(:custom_type) { type(:read_type) }
let(:object_in_field) { double('presented in field') }
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
index fdacecbaca6..ba77bc95bb5 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
@@ -9,6 +9,14 @@ describe Gitlab::Graphql::Pagination::Keyset::Connection do
let(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)}
let(:context) { GraphQL::Query::Context.new(query: OpenStruct.new(schema: schema), values: nil, object: nil) }
+ before do
+ stub_const('NoPrimaryKey', Class.new(ActiveRecord::Base))
+ NoPrimaryKey.class_eval do
+ self.table_name = 'no_primary_key'
+ self.primary_key = nil
+ end
+ end
+
subject(:connection) do
described_class.new(nodes, { context: context, max_page_size: 3 }.merge(arguments))
end
@@ -18,7 +26,7 @@ describe Gitlab::Graphql::Pagination::Keyset::Connection do
end
def decoded_cursor(cursor)
- JSON.parse(Base64Bp.urlsafe_decode64(cursor))
+ Gitlab::Json.parse(Base64Bp.urlsafe_decode64(cursor))
end
describe '#cursor_for' do
@@ -303,9 +311,4 @@ describe Gitlab::Graphql::Pagination::Keyset::Connection do
end
end
end
-
- class NoPrimaryKey < ActiveRecord::Base
- self.table_name = 'no_primary_key'
- self.primary_key = nil
- end
end
diff --git a/spec/lib/gitlab/graphql_logger_spec.rb b/spec/lib/gitlab/graphql_logger_spec.rb
index 4977f98b83e..12cb56c78c1 100644
--- a/spec/lib/gitlab/graphql_logger_spec.rb
+++ b/spec/lib/gitlab/graphql_logger_spec.rb
@@ -23,18 +23,18 @@ describe Gitlab::GraphqlLogger do
variables: {},
complexity: 181,
depth: 0,
- duration: 7
+ duration_s: 7
}
output = subject.format_message('INFO', now, 'test', analyzer_memo)
- data = JSON.parse(output)
+ data = Gitlab::Json.parse(output)
expect(data['severity']).to eq('INFO')
expect(data['time']).to eq(now.utc.iso8601(3))
expect(data['complexity']).to eq(181)
expect(data['variables']).to eq({})
expect(data['depth']).to eq(0)
- expect(data['duration']).to eq(7)
+ expect(data['duration_s']).to eq(7)
end
end
end
diff --git a/spec/lib/gitlab/health_checks/master_check_spec.rb b/spec/lib/gitlab/health_checks/master_check_spec.rb
index cb20c1188af..dcfc733d5ad 100644
--- a/spec/lib/gitlab/health_checks/master_check_spec.rb
+++ b/spec/lib/gitlab/health_checks/master_check_spec.rb
@@ -6,10 +6,9 @@ require_relative './simple_check_shared'
describe Gitlab::HealthChecks::MasterCheck do
let(:result_class) { Gitlab::HealthChecks::Result }
- SUCCESS_CODE = 100
- FAILURE_CODE = 101
-
before do
+ stub_const('SUCCESS_CODE', 100)
+ stub_const('FAILURE_CODE', 101)
described_class.register_master
end
diff --git a/spec/lib/gitlab/hook_data/issuable_builder_spec.rb b/spec/lib/gitlab/hook_data/issuable_builder_spec.rb
index cff489e0f3b..afbc48e9ca2 100644
--- a/spec/lib/gitlab/hook_data/issuable_builder_spec.rb
+++ b/spec/lib/gitlab/hook_data/issuable_builder_spec.rb
@@ -12,6 +12,7 @@ describe Gitlab::HookData::IssuableBuilder do
include_examples 'project hook data' do
let(:project) { builder.issuable.project }
end
+
include_examples 'deprecated repository hook data'
context "with a #{kind}" do
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 5d5e2fe2a33..c78b4501310 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -6,10 +6,12 @@ issues:
- assignees
- updated_by
- milestone
+- iteration
- notes
- resource_label_events
- resource_weight_events
- resource_milestone_events
+- resource_state_events
- sent_notifications
- sentry_issue
- label_links
@@ -18,6 +20,7 @@ issues:
- todos
- user_agent_detail
- moved_to
+- moved_from
- duplicated_to
- promoted_to_epic
- events
@@ -39,6 +42,8 @@ issues:
- related_vulnerabilities
- user_mentions
- system_note_metadata
+- alert_management_alert
+- status_page_published_incident
events:
- author
- project
@@ -111,9 +116,11 @@ merge_requests:
- assignee
- updated_by
- milestone
+- iteration
- notes
- resource_label_events
- resource_milestone_events
+- resource_state_events
- label_links
- labels
- last_edited_by
@@ -212,7 +219,7 @@ ci_pipelines:
- vulnerability_findings
- pipeline_config
- security_scans
-- daily_report_results
+- daily_build_group_report_results
pipeline_variables:
- pipeline
stages:
@@ -222,6 +229,7 @@ stages:
- processables
- builds
- bridges
+- latest_statuses
statuses:
- project
- pipeline
@@ -343,6 +351,7 @@ project:
- labels
- events
- milestones
+- iterations
- notes
- snippets
- hooks
@@ -420,7 +429,6 @@ project:
- mirror_user
- push_rule
- jenkins_service
-- jenkins_deprecated_service
- index_status
- feature_usage
- approval_rules
@@ -443,6 +451,7 @@ project:
- vulnerability_scanners
- operations_feature_flags
- operations_feature_flags_client
+- operations_feature_flags_user_lists
- prometheus_alerts
- prometheus_alert_events
- self_managed_prometheus_alert_events
@@ -477,9 +486,14 @@ project:
- status_page_setting
- requirements
- export_jobs
-- daily_report_results
+- daily_build_group_report_results
- jira_imports
- compliance_framework_setting
+- metrics_users_starred_dashboards
+- alert_management_alerts
+- repository_storage_moves
+- freeze_periods
+- webex_teams_service
award_emoji:
- awardable
- user
@@ -631,3 +645,5 @@ epic_issue:
system_note_metadata:
- note
- description_version
+status_page_published_incident:
+- issue
diff --git a/spec/lib/gitlab/import_export/attribute_configuration_spec.rb b/spec/lib/gitlab/import_export/attribute_configuration_spec.rb
index 58da25bbedb..f97dafc6bf9 100644
--- a/spec/lib/gitlab/import_export/attribute_configuration_spec.rb
+++ b/spec/lib/gitlab/import_export/attribute_configuration_spec.rb
@@ -43,7 +43,4 @@ describe 'Import/Export attribute configuration' do
IMPORT_EXPORT_CONFIG: #{Gitlab::ImportExport.config_file}
MSG
end
-
- class Author < User
- end
end
diff --git a/spec/lib/gitlab/import_export/design_repo_restorer_spec.rb b/spec/lib/gitlab/import_export/design_repo_restorer_spec.rb
new file mode 100644
index 00000000000..5662b8af280
--- /dev/null
+++ b/spec/lib/gitlab/import_export/design_repo_restorer_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::ImportExport::DesignRepoRestorer do
+ include GitHelpers
+
+ describe 'bundle a design Git repo' do
+ let(:user) { create(:user) }
+ let!(:project_with_design_repo) { create(:project, :design_repo) }
+ let!(:project) { create(:project) }
+ let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
+ let(:shared) { project.import_export_shared }
+ let(:bundler) { Gitlab::ImportExport::DesignRepoSaver.new(project: project_with_design_repo, shared: shared) }
+ let(:bundle_path) { File.join(shared.export_path, Gitlab::ImportExport.design_repo_bundle_filename) }
+ let(:restorer) do
+ described_class.new(path_to_bundle: bundle_path,
+ shared: shared,
+ project: project)
+ end
+
+ before do
+ allow_next_instance_of(Gitlab::ImportExport) do |instance|
+ allow(instance).to receive(:storage_path).and_return(export_path)
+ end
+
+ bundler.save
+ end
+
+ after do
+ FileUtils.rm_rf(export_path)
+ Gitlab::GitalyClient::StorageSettings.allow_disk_access do
+ FileUtils.rm_rf(project_with_design_repo.design_repository.path_to_repo)
+ FileUtils.rm_rf(project.design_repository.path_to_repo)
+ end
+ end
+
+ it 'restores the repo successfully' do
+ expect(restorer.restore).to eq(true)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_export/design_repo_saver_spec.rb b/spec/lib/gitlab/import_export/design_repo_saver_spec.rb
new file mode 100644
index 00000000000..bff48e8b52a
--- /dev/null
+++ b/spec/lib/gitlab/import_export/design_repo_saver_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::ImportExport::DesignRepoSaver do
+ describe 'bundle a design Git repo' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:design) { create(:design, :with_file, versions_count: 1) }
+ let!(:project) { create(:project, :design_repo) }
+ let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
+ let(:shared) { project.import_export_shared }
+ let(:design_bundler) { described_class.new(project: project, shared: shared) }
+
+ before do
+ project.add_maintainer(user)
+ allow_next_instance_of(Gitlab::ImportExport) do |instance|
+ allow(instance).to receive(:storage_path).and_return(export_path)
+ end
+ end
+
+ after do
+ FileUtils.rm_rf(export_path)
+ end
+
+ it 'bundles the repo successfully' do
+ expect(design_bundler.save).to be true
+ end
+
+ context 'when the repo is empty' do
+ let!(:project) { create(:project) }
+
+ it 'bundles the repo successfully' do
+ expect(design_bundler.save).to be true
+ end
+ end
+ end
+end
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 15058684229..916ed692a05 100644
--- a/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb
+++ b/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb
@@ -8,7 +8,7 @@ describe Gitlab::ImportExport::FastHashSerializer do
# Wrapping the result into JSON generating/parsing is for making
# the testing more convenient. Doing this, we can check that
# all items are properly serialized while traversing the simple hash.
- subject { JSON.parse(JSON.generate(described_class.new(project, tree).execute)) }
+ subject { Gitlab::Json.parse(Gitlab::Json.generate(described_class.new(project, tree).execute)) }
let!(:project) { setup_project }
let(:user) { create(:user) }
diff --git a/spec/lib/gitlab/import_export/group/legacy_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/group/legacy_tree_restorer_spec.rb
index 3030cdf4cf8..4c926da1436 100644
--- a/spec/lib/gitlab/import_export/group/legacy_tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/group/legacy_tree_restorer_spec.rb
@@ -141,7 +141,7 @@ describe Gitlab::ImportExport::Group::LegacyTreeRestorer do
let(:filepath) { "group_exports/visibility_levels/#{visibility_level}" }
it "imports all subgroups as #{visibility_level}" do
- expect(group.children.map(&:visibility_level)).to eq(expected_visibilities)
+ expect(group.children.map(&:visibility_level)).to match_array(expected_visibilities)
end
end
end
diff --git a/spec/lib/gitlab/import_export/group/tree_restorer_spec.rb b/spec/lib/gitlab/import_export/group/tree_restorer_spec.rb
new file mode 100644
index 00000000000..327f36c664e
--- /dev/null
+++ b/spec/lib/gitlab/import_export/group/tree_restorer_spec.rb
@@ -0,0 +1,184 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::ImportExport::Group::TreeRestorer do
+ include ImportExport::CommonUtil
+
+ describe 'restore group tree' do
+ before_all do
+ # Using an admin for import, so we can check assignment of existing members
+ user = create(:admin, email: 'root@gitlabexample.com')
+ create(:user, email: 'adriene.mcclure@gitlabexample.com')
+ create(:user, email: 'gwendolyn_robel@gitlabexample.com')
+
+ RSpec::Mocks.with_temporary_scope do
+ @group = create(:group, name: 'group', path: 'group')
+ @shared = Gitlab::ImportExport::Shared.new(@group)
+
+ setup_import_export_config('group_exports/complex')
+
+ group_tree_restorer = described_class.new(user: user, shared: @shared, group: @group)
+
+ expect(group_tree_restorer.restore).to be_truthy
+ end
+ end
+
+ it 'has the group description' do
+ expect(Group.find_by_path('group').description).to eq('Group Description')
+ end
+
+ it 'has group labels' do
+ expect(@group.labels.count).to eq(10)
+ end
+
+ context 'issue boards' do
+ it 'has issue boards' do
+ expect(@group.boards.count).to eq(1)
+ end
+
+ it 'has board label lists' do
+ lists = @group.boards.find_by(name: 'first board').lists
+
+ expect(lists.count).to eq(3)
+ expect(lists.first.label.title).to eq('TSL')
+ expect(lists.second.label.title).to eq('Sosync')
+ end
+ end
+
+ it 'has badges' do
+ expect(@group.badges.count).to eq(1)
+ end
+
+ it 'has milestones' do
+ expect(@group.milestones.count).to eq(5)
+ end
+
+ it 'has group children' do
+ expect(@group.children.count).to eq(2)
+ end
+
+ it 'has group members' do
+ expect(@group.members.map(&:user).map(&:email)).to contain_exactly(
+ 'root@gitlabexample.com',
+ 'adriene.mcclure@gitlabexample.com',
+ 'gwendolyn_robel@gitlabexample.com'
+ )
+ end
+ end
+
+ context 'child with no parent' do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:shared) { Gitlab::ImportExport::Shared.new(group) }
+ let(:group_tree_restorer) { described_class.new(user: user, shared: shared, group: group) }
+
+ before do
+ setup_import_export_config('group_exports/child_with_no_parent')
+
+ expect(group_tree_restorer.restore).to be_falsey
+ end
+
+ it 'fails when a child group does not have a valid parent_id' do
+ expect(shared.errors).to include('Parent group not found')
+ end
+ end
+
+ context 'excluded attributes' do
+ let!(:source_user) { create(:user, id: 123) }
+ let!(:importer_user) { create(:user) }
+ let(:group) { create(:group, name: 'user-inputed-name', path: 'user-inputed-path') }
+ let(:shared) { Gitlab::ImportExport::Shared.new(group) }
+ let(:group_tree_restorer) { described_class.new(user: importer_user, shared: shared, group: group) }
+ let(:exported_file) { File.join(shared.export_path, 'tree/groups/4352.json') }
+ let(:group_json) { ActiveSupport::JSON.decode(IO.read(exported_file)) }
+
+ shared_examples 'excluded attributes' do
+ excluded_attributes = %w[
+ id
+ parent_id
+ owner_id
+ created_at
+ updated_at
+ runners_token
+ runners_token_encrypted
+ saml_discovery_token
+ ]
+
+ before do
+ group.add_owner(importer_user)
+
+ setup_import_export_config('group_exports/complex')
+
+ expect(File.exist?(exported_file)).to be_truthy
+
+ group_tree_restorer.restore
+ group.reload
+ end
+
+ it 'does not import root group name' do
+ expect(group.name).to eq('user-inputed-name')
+ end
+
+ it 'does not import root group path' do
+ expect(group.path).to eq('user-inputed-path')
+ end
+
+ excluded_attributes.each do |excluded_attribute|
+ it 'does not allow override of excluded attributes' do
+ unless group.public_send(excluded_attribute).nil?
+ expect(group_json[excluded_attribute]).not_to eq(group.public_send(excluded_attribute))
+ end
+ end
+ end
+ end
+
+ include_examples 'excluded attributes'
+ end
+
+ context 'group.json file access check' do
+ let(:user) { create(:user) }
+ let!(:group) { create(:group, name: 'group2', path: 'group2') }
+ let(:shared) { Gitlab::ImportExport::Shared.new(group) }
+ let(:group_tree_restorer) { described_class.new(user: user, shared: shared, group: group) }
+
+ it 'does not read a symlink' do
+ Dir.mktmpdir do |tmpdir|
+ FileUtils.mkdir_p(File.join(tmpdir, 'tree', 'groups'))
+ setup_symlink(tmpdir, 'tree/groups/_all.ndjson')
+
+ allow(shared).to receive(:export_path).and_return(tmpdir)
+
+ expect(group_tree_restorer.restore).to eq(false)
+ expect(shared.errors).to include('Incorrect JSON format')
+ end
+ end
+ end
+
+ context 'group visibility levels' do
+ let(:user) { create(:user) }
+ let(:shared) { Gitlab::ImportExport::Shared.new(group) }
+ let(:group_tree_restorer) { described_class.new(user: user, shared: shared, group: group) }
+
+ before do
+ setup_import_export_config(filepath)
+
+ group_tree_restorer.restore
+ end
+
+ shared_examples 'with visibility level' do |visibility_level, expected_visibilities|
+ context "when visibility level is #{visibility_level}" do
+ let(:group) { create(:group, visibility_level) }
+ let(:filepath) { "group_exports/visibility_levels/#{visibility_level}" }
+
+ it "imports all subgroups as #{visibility_level}" do
+ expect(group.children.map(&:visibility_level)).to eq(expected_visibilities)
+ end
+ end
+ end
+
+ include_examples 'with visibility level', :public, [20, 10, 0]
+ include_examples 'with visibility level', :private, [0, 0, 0]
+ include_examples 'with visibility level', :internal, [10, 10, 0]
+ end
+end
diff --git a/spec/lib/gitlab/import_export/group/tree_saver_spec.rb b/spec/lib/gitlab/import_export/group/tree_saver_spec.rb
new file mode 100644
index 00000000000..06e8484a3cb
--- /dev/null
+++ b/spec/lib/gitlab/import_export/group/tree_saver_spec.rb
@@ -0,0 +1,140 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::ImportExport::Group::TreeSaver do
+ describe 'saves the group tree into a json object' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { setup_groups }
+
+ let(:shared) { Gitlab::ImportExport::Shared.new(group) }
+ let(:export_path) { "#{Dir.tmpdir}/group_tree_saver_spec" }
+
+ subject(:group_tree_saver) { described_class.new(group: group, current_user: user, shared: shared) }
+
+ before_all do
+ group.add_maintainer(user)
+ end
+
+ before do
+ allow_next_instance_of(Gitlab::ImportExport) do |import_export|
+ allow(import_export).to receive(:storage_path).and_return(export_path)
+ end
+ end
+
+ after do
+ FileUtils.rm_rf(export_path)
+ end
+
+ it 'saves the group successfully' do
+ expect(group_tree_saver.save).to be true
+ end
+
+ it 'fails to export a group' do
+ allow_next_instance_of(Gitlab::ImportExport::JSON::NdjsonWriter) do |ndjson_writer|
+ allow(ndjson_writer).to receive(:write_relation_array).and_raise(RuntimeError, 'exception')
+ end
+
+ expect(shared).to receive(:error).with(RuntimeError).and_call_original
+
+ expect(group_tree_saver.save).to be false
+ end
+
+ context 'exported files' do
+ before do
+ group_tree_saver.save
+ end
+
+ it 'has one group per line' do
+ groups_catalog =
+ File.readlines(exported_path_for('_all.ndjson'))
+ .map { |line| Integer(line) }
+
+ expect(groups_catalog.size).to eq(3)
+ expect(groups_catalog).to eq([
+ group.id,
+ group.descendants.first.id,
+ group.descendants.first.descendants.first.id
+ ])
+ end
+
+ it 'has a file per group' do
+ group.self_and_descendants.pluck(:id).each do |id|
+ group_attributes_file = exported_path_for("#{id}.json")
+
+ expect(File.exist?(group_attributes_file)).to be(true)
+ end
+ end
+
+ context 'group attributes file' do
+ let(:group_attributes_file) { exported_path_for("#{group.id}.json") }
+ let(:group_attributes) { ::JSON.parse(File.read(group_attributes_file)) }
+
+ it 'has a file for each group with its attributes' do
+ expect(group_attributes['description']).to eq(group.description)
+ expect(group_attributes['parent_id']).to eq(group.parent_id)
+ end
+
+ shared_examples 'excluded attributes' do
+ excluded_attributes = %w[
+ owner_id
+ created_at
+ updated_at
+ runners_token
+ runners_token_encrypted
+ saml_discovery_token
+ ]
+
+ excluded_attributes.each do |excluded_attribute|
+ it 'does not contain excluded attribute' do
+ expect(group_attributes).not_to include(excluded_attribute => group.public_send(excluded_attribute))
+ end
+ end
+ end
+
+ include_examples 'excluded attributes'
+ end
+
+ it 'has a file for each group association' do
+ group.self_and_descendants do |g|
+ %w[
+ badges
+ boards
+ epics
+ labels
+ members
+ milestones
+ ].each do |association|
+ path = exported_path_for("#{g.id}", "#{association}.ndjson")
+ expect(File.exist?(path)).to eq(true), "#{path} does not exist"
+ end
+ end
+ end
+ end
+ end
+
+ def exported_path_for(*file)
+ File.join(group_tree_saver.full_path, 'groups', *file)
+ end
+
+ def setup_groups
+ root = setup_group
+ subgroup = setup_group(parent: root)
+ setup_group(parent: subgroup)
+
+ root
+ end
+
+ def setup_group(parent: nil)
+ group = create(:group, description: 'description', parent: parent)
+ create(:milestone, group: group)
+ create(:group_badge, group: group)
+ group_label = create(:group_label, group: group)
+ board = create(:board, group: group, milestone_id: Milestone::Upcoming.id)
+ create(:list, board: board, label: group_label)
+ create(:group_badge, group: group)
+ create(:label_priority, label: group_label, priority: 1)
+
+ group
+ end
+end
diff --git a/spec/lib/gitlab/import_export/import_export_equivalence_spec.rb b/spec/lib/gitlab/import_export/import_export_equivalence_spec.rb
index 707975f20b6..95df9cd0e6e 100644
--- a/spec/lib/gitlab/import_export/import_export_equivalence_spec.rb
+++ b/spec/lib/gitlab/import_export/import_export_equivalence_spec.rb
@@ -46,8 +46,8 @@ describe Gitlab::ImportExport do
export_path: test_tmp_path)
).to be true
- imported_json = JSON.parse(File.read("#{test_fixture_path}/project.json"))
- exported_json = JSON.parse(File.read("#{test_tmp_path}/project.json"))
+ imported_json = Gitlab::Json.parse(File.read("#{test_fixture_path}/project.json"))
+ exported_json = Gitlab::Json.parse(File.read("#{test_tmp_path}/project.json"))
assert_relations_match(imported_json, exported_json)
end
diff --git a/spec/lib/gitlab/import_export/import_test_coverage_spec.rb b/spec/lib/gitlab/import_export/import_test_coverage_spec.rb
index 335b0031147..038b95809b4 100644
--- a/spec/lib/gitlab/import_export/import_test_coverage_spec.rb
+++ b/spec/lib/gitlab/import_export/import_test_coverage_spec.rb
@@ -53,22 +53,15 @@ describe 'Test coverage of the Project Import' do
].freeze
# A list of JSON fixture files we use to test Import.
- # Note that we use separate fixture to test ee-only features.
# Most of the relations are present in `complex/project.json`
# which is our main fixture.
- PROJECT_JSON_FIXTURES_EE =
- if Gitlab.ee?
- ['ee/spec/fixtures/lib/gitlab/import_export/designs/project.json'].freeze
- else
- []
- end
-
PROJECT_JSON_FIXTURES = [
'spec/fixtures/lib/gitlab/import_export/complex/project.json',
'spec/fixtures/lib/gitlab/import_export/group/project.json',
'spec/fixtures/lib/gitlab/import_export/light/project.json',
- 'spec/fixtures/lib/gitlab/import_export/milestone-iid/project.json'
- ].freeze + PROJECT_JSON_FIXTURES_EE
+ 'spec/fixtures/lib/gitlab/import_export/milestone-iid/project.json',
+ 'spec/fixtures/lib/gitlab/import_export/designs/project.json'
+ ].freeze
it 'ensures that all imported/exported relations are present in test JSONs' do
not_tested_relations = (relations_from_config - tested_relations) - MUTED_RELATIONS
diff --git a/spec/lib/gitlab/import_export/importer_spec.rb b/spec/lib/gitlab/import_export/importer_spec.rb
index e03c95525df..60179146416 100644
--- a/spec/lib/gitlab/import_export/importer_spec.rb
+++ b/spec/lib/gitlab/import_export/importer_spec.rb
@@ -51,7 +51,8 @@ describe Gitlab::ImportExport::Importer do
Gitlab::ImportExport::UploadsRestorer,
Gitlab::ImportExport::LfsRestorer,
Gitlab::ImportExport::StatisticsRestorer,
- Gitlab::ImportExport::SnippetsRepoRestorer
+ Gitlab::ImportExport::SnippetsRepoRestorer,
+ Gitlab::ImportExport::DesignRepoRestorer
].each do |restorer|
it "calls the #{restorer}" do
fake_restorer = double(restorer.to_s)
@@ -89,36 +90,74 @@ describe Gitlab::ImportExport::Importer do
end
context 'when project successfully restored' do
- let!(:existing_project) { create(:project, namespace: user.namespace) }
- let(:project) { create(:project, namespace: user.namespace, name: 'whatever', path: 'whatever') }
+ context "with a project in a user's namespace" do
+ let!(:existing_project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace, name: 'whatever', path: 'whatever') }
- before do
- restorers = double(:restorers, all?: true)
+ before do
+ restorers = double(:restorers, all?: true)
- allow(subject).to receive(:import_file).and_return(true)
- allow(subject).to receive(:check_version!).and_return(true)
- allow(subject).to receive(:restorers).and_return(restorers)
- allow(project).to receive(:import_data).and_return(double(data: { 'original_path' => existing_project.path }))
+ allow(subject).to receive(:import_file).and_return(true)
+ allow(subject).to receive(:check_version!).and_return(true)
+ allow(subject).to receive(:restorers).and_return(restorers)
+ allow(project).to receive(:import_data).and_return(double(data: { 'original_path' => existing_project.path }))
+ end
+
+ context 'when import_data' do
+ context 'has original_path' do
+ it 'overwrites existing project' do
+ expect_next_instance_of(::Projects::OverwriteProjectService) do |service|
+ expect(service).to receive(:execute).with(existing_project)
+ end
+
+ subject.execute
+ end
+ end
+
+ context 'has not original_path' do
+ before do
+ allow(project).to receive(:import_data).and_return(double(data: {}))
+ end
+
+ it 'does not call the overwrite service' do
+ expect(::Projects::OverwriteProjectService).not_to receive(:new)
+
+ subject.execute
+ end
+ end
+ end
end
- context 'when import_data' do
+ context "with a project in a group namespace" do
+ let(:group) { create(:group) }
+ let!(:existing_project) { create(:project, group: group) }
+ let(:project) { create(:project, creator: user, group: group, name: 'whatever', path: 'whatever') }
+
+ before do
+ restorers = double(:restorers, all?: true)
+
+ allow(subject).to receive(:import_file).and_return(true)
+ allow(subject).to receive(:check_version!).and_return(true)
+ allow(subject).to receive(:restorers).and_return(restorers)
+ allow(project).to receive(:import_data).and_return(double(data: { 'original_path' => existing_project.path }))
+ end
+
context 'has original_path' do
it 'overwrites existing project' do
- expect_any_instance_of(::Projects::OverwriteProjectService).to receive(:execute).with(existing_project)
+ group.add_owner(user)
- subject.execute
- end
- end
+ expect_next_instance_of(::Projects::OverwriteProjectService) do |service|
+ expect(service).to receive(:execute).with(existing_project)
+ end
- context 'has not original_path' do
- before do
- allow(project).to receive(:import_data).and_return(double(data: {}))
+ subject.execute
end
- it 'does not call the overwrite service' do
- expect_any_instance_of(::Projects::OverwriteProjectService).not_to receive(:execute).with(existing_project)
+ it 'does not allow user to overwrite existing project' do
+ expect(::Projects::OverwriteProjectService).not_to receive(:new)
- subject.execute
+ expect { subject.execute }.to raise_error(Projects::ImportService::Error,
+ "User #{user.username} (#{user.id}) cannot overwrite a project in #{group.path}")
end
end
end
diff --git a/spec/lib/gitlab/import_export/json/legacy_reader/file_spec.rb b/spec/lib/gitlab/import_export/json/legacy_reader/file_spec.rb
index 1021ce3cd50..99932404fd9 100644
--- a/spec/lib/gitlab/import_export/json/legacy_reader/file_spec.rb
+++ b/spec/lib/gitlab/import_export/json/legacy_reader/file_spec.rb
@@ -7,7 +7,7 @@ describe Gitlab::ImportExport::JSON::LegacyReader::File do
it_behaves_like 'import/export json legacy reader' do
let(:valid_path) { 'spec/fixtures/lib/gitlab/import_export/light/project.json' }
let(:data) { valid_path }
- let(:json_data) { JSON.parse(File.read(valid_path)) }
+ let(:json_data) { Gitlab::Json.parse(File.read(valid_path)) }
end
describe '#exist?' do
diff --git a/spec/lib/gitlab/import_export/json/legacy_reader/hash_spec.rb b/spec/lib/gitlab/import_export/json/legacy_reader/hash_spec.rb
index 8c4dfd2f356..e793dc7339d 100644
--- a/spec/lib/gitlab/import_export/json/legacy_reader/hash_spec.rb
+++ b/spec/lib/gitlab/import_export/json/legacy_reader/hash_spec.rb
@@ -9,8 +9,8 @@ describe Gitlab::ImportExport::JSON::LegacyReader::Hash do
# the hash is modified by the `LegacyReader`
# we need to deep-dup it
- let(:json_data) { JSON.parse(File.read(path)) }
- let(:data) { JSON.parse(File.read(path)) }
+ let(:json_data) { Gitlab::Json.parse(File.read(path)) }
+ let(:data) { Gitlab::Json.parse(File.read(path)) }
end
describe '#exist?' do
diff --git a/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb b/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb
index 40b784fdb87..34e8b1ddd59 100644
--- a/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb
+++ b/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb
@@ -6,18 +6,10 @@ describe Gitlab::ImportExport::JSON::NdjsonReader do
include ImportExport::CommonUtil
let(:fixture) { 'spec/fixtures/lib/gitlab/import_export/light/tree' }
- let(:root_tree) { JSON.parse(File.read(File.join(fixture, 'project.json'))) }
+ let(:root_tree) { Gitlab::Json.parse(File.read(File.join(fixture, 'project.json'))) }
let(:ndjson_reader) { described_class.new(dir_path) }
let(:importable_path) { 'project' }
- before :all do
- extract_archive('spec/fixtures/lib/gitlab/import_export/light', 'tree.tar.gz')
- end
-
- after :all do
- cleanup_artifacts_from_extract_archive('light')
- end
-
describe '#exist?' do
subject { ndjson_reader.exist? }
@@ -101,8 +93,8 @@ describe Gitlab::ImportExport::JSON::NdjsonReader do
context 'relation file contains multiple lines' do
let(:key) { 'custom_attributes' }
- let(:attr_1) { JSON.parse('{"id":201,"project_id":5,"created_at":"2016-06-14T15:01:51.315Z","updated_at":"2016-06-14T15:01:51.315Z","key":"color","value":"red"}') }
- let(:attr_2) { JSON.parse('{"id":202,"project_id":5,"created_at":"2016-06-14T15:01:51.315Z","updated_at":"2016-06-14T15:01:51.315Z","key":"size","value":"small"}') }
+ let(:attr_1) { Gitlab::Json.parse('{"id":201,"project_id":5,"created_at":"2016-06-14T15:01:51.315Z","updated_at":"2016-06-14T15:01:51.315Z","key":"color","value":"red"}') }
+ let(:attr_2) { Gitlab::Json.parse('{"id":202,"project_id":5,"created_at":"2016-06-14T15:01:51.315Z","updated_at":"2016-06-14T15:01:51.315Z","key":"size","value":"small"}') }
it 'yields every relation value to the Enumerator' do
expect(subject.to_a).to eq([[attr_1, 0], [attr_2, 1]])
diff --git a/spec/lib/gitlab/import_export/lfs_saver_spec.rb b/spec/lib/gitlab/import_export/lfs_saver_spec.rb
index a8ff7867410..e9d06573e70 100644
--- a/spec/lib/gitlab/import_export/lfs_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/lfs_saver_spec.rb
@@ -26,7 +26,7 @@ describe Gitlab::ImportExport::LfsSaver do
let(:lfs_json_file) { File.join(shared.export_path, Gitlab::ImportExport.lfs_objects_filename) }
def lfs_json
- JSON.parse(IO.read(lfs_json_file))
+ Gitlab::Json.parse(IO.read(lfs_json_file))
end
before do
diff --git a/spec/lib/gitlab/import_export/project/export_task_spec.rb b/spec/lib/gitlab/import_export/project/export_task_spec.rb
index cf11a1df33c..dc8eb54dc14 100644
--- a/spec/lib/gitlab/import_export/project/export_task_spec.rb
+++ b/spec/lib/gitlab/import_export/project/export_task_spec.rb
@@ -3,13 +3,14 @@
require 'rake_helper'
describe Gitlab::ImportExport::Project::ExportTask do
- let(:username) { 'root' }
+ let_it_be(:username) { 'root' }
let(:namespace_path) { username }
- let!(:user) { create(:user, username: username) }
+ let_it_be(:user) { create(:user, username: username) }
let(:measurement_enabled) { false }
let(:file_path) { 'spec/fixtures/gitlab/import_export/test_project_export.tar.gz' }
let(:project) { create(:project, creator: user, namespace: user.namespace) }
let(:project_name) { project.name }
+ let(:rake_task) { described_class.new(task_params) }
let(:task_params) do
{
@@ -21,7 +22,7 @@ describe Gitlab::ImportExport::Project::ExportTask do
}
end
- subject { described_class.new(task_params).export }
+ subject { rake_task.export }
context 'when project is found' do
let(:project) { create(:project, creator: user, namespace: user.namespace) }
@@ -29,9 +30,13 @@ describe Gitlab::ImportExport::Project::ExportTask do
around do |example|
example.run
ensure
- File.delete(file_path)
+ File.delete(file_path) if File.exist?(file_path)
end
+ include_context 'rake task object storage shared context'
+
+ it_behaves_like 'rake task with disabled object_storage', ::Projects::ImportExport::ExportService, :success
+
it 'performs project export successfully' do
expect { subject }.to output(/Done!/).to_stdout
@@ -39,8 +44,6 @@ describe Gitlab::ImportExport::Project::ExportTask do
expect(File).to exist(file_path)
end
-
- it_behaves_like 'measurable'
end
context 'when project is not found' do
@@ -66,4 +69,32 @@ describe Gitlab::ImportExport::Project::ExportTask do
expect(subject).to eq(false)
end
end
+
+ context 'when after export strategy fails' do
+ before do
+ allow_next_instance_of(Gitlab::ImportExport::AfterExportStrategies::MoveFileStrategy) do |after_export_strategy|
+ allow(after_export_strategy).to receive(:strategy_execute).and_raise(Gitlab::ImportExport::AfterExportStrategies::BaseAfterExportStrategy::StrategyError)
+ end
+ end
+
+ it 'error is logged' do
+ expect(rake_task).to receive(:error).and_call_original
+
+ expect(subject).to eq(false)
+ end
+ end
+
+ context 'when saving services fail' do
+ before do
+ allow_next_instance_of(::Projects::ImportExport::ExportService) do |service|
+ allow(service).to receive(:execute).and_raise(Gitlab::ImportExport::Error)
+ end
+ end
+
+ it 'error is logged' do
+ expect(rake_task).to receive(:error).and_call_original
+
+ expect(subject).to eq(false)
+ end
+ end
end
diff --git a/spec/lib/gitlab/import_export/project/import_task_spec.rb b/spec/lib/gitlab/import_export/project/import_task_spec.rb
index 4f4fcd3ad8a..7c11161aaa7 100644
--- a/spec/lib/gitlab/import_export/project/import_task_spec.rb
+++ b/spec/lib/gitlab/import_export/project/import_task_spec.rb
@@ -8,7 +8,7 @@ describe Gitlab::ImportExport::Project::ImportTask, :request_store do
let!(:user) { create(:user, username: username) }
let(:measurement_enabled) { false }
let(:project) { Project.find_by_full_path("#{namespace_path}/#{project_name}") }
- let(:import_task) { described_class.new(task_params) }
+ let(:rake_task) { described_class.new(task_params) }
let(:task_params) do
{
username: username,
@@ -19,29 +19,16 @@ describe Gitlab::ImportExport::Project::ImportTask, :request_store do
}
end
- before do
- allow(Settings.uploads.object_store).to receive(:[]=).and_call_original
- end
-
- around do |example|
- old_direct_upload_setting = Settings.uploads.object_store['direct_upload']
- old_background_upload_setting = Settings.uploads.object_store['background_upload']
-
- Settings.uploads.object_store['direct_upload'] = true
- Settings.uploads.object_store['background_upload'] = true
-
- example.run
-
- Settings.uploads.object_store['direct_upload'] = old_direct_upload_setting
- Settings.uploads.object_store['background_upload'] = old_background_upload_setting
- end
-
- subject { import_task.import }
+ subject { rake_task.import }
context 'when project import is valid' do
let(:project_name) { 'import_rake_test_project' }
let(:file_path) { 'spec/fixtures/gitlab/import_export/lightweight_project_export.tar.gz' }
+ include_context 'rake task object storage shared context'
+
+ it_behaves_like 'rake task with disabled object_storage', ::Projects::GitlabProjectsImportService, :execute_sidekiq_job
+
it 'performs project import successfully' do
expect { subject }.to output(/Done!/).to_stdout
expect { subject }.not_to raise_error
@@ -52,30 +39,6 @@ describe Gitlab::ImportExport::Project::ImportTask, :request_store do
expect(project.milestones.count).to be > 0
expect(project.import_state.status).to eq('finished')
end
-
- it 'disables direct & background upload only during project creation' do
- expect_next_instance_of(Projects::GitlabProjectsImportService) do |service|
- expect(service).to receive(:execute).and_wrap_original do |m|
- expect(Settings.uploads.object_store['background_upload']).to eq(false)
- expect(Settings.uploads.object_store['direct_upload']).to eq(false)
-
- m.call
- end
- end
-
- expect(import_task).to receive(:execute_sidekiq_job).and_wrap_original do |m|
- expect(Settings.uploads.object_store['background_upload']).to eq(true)
- expect(Settings.uploads.object_store['direct_upload']).to eq(true)
- expect(Settings.uploads.object_store).not_to receive(:[]=).with('backgroud_upload', false)
- expect(Settings.uploads.object_store).not_to receive(:[]=).with('direct_upload', false)
-
- m.call
- end
-
- subject
- end
-
- it_behaves_like 'measurable'
end
context 'when project import is invalid' do
diff --git a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
index 04e8bd05666..58589a7bbbe 100644
--- a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
@@ -8,6 +8,7 @@ end
describe Gitlab::ImportExport::Project::TreeRestorer do
include ImportExport::CommonUtil
+ using RSpec::Parameterized::TableSyntax
let(:shared) { project.import_export_shared }
@@ -44,10 +45,6 @@ describe Gitlab::ImportExport::Project::TreeRestorer do
end
end
- after(:context) do
- cleanup_artifacts_from_extract_archive('complex')
- end
-
context 'JSON' do
it 'restores models based on JSON' do
expect(@restored_project_json).to be_truthy
@@ -536,10 +533,6 @@ describe Gitlab::ImportExport::Project::TreeRestorer do
expect(restored_project_json).to eq(true)
end
- after do
- cleanup_artifacts_from_extract_archive('light')
- end
-
it 'issue system note metadata restored successfully' do
note_content = 'created merge request !1 to address this issue'
note = project.issues.first.notes.select { |n| n.note.match(/#{note_content}/)}.first
@@ -586,10 +579,6 @@ describe Gitlab::ImportExport::Project::TreeRestorer do
expect(restored_project_json).to eq(true)
end
- after do
- cleanup_artifacts_from_extract_archive('multi_pipeline_ref_one_external_pr')
- end
-
it_behaves_like 'restores project successfully',
issues: 0,
labels: 0,
@@ -620,10 +609,6 @@ describe Gitlab::ImportExport::Project::TreeRestorer do
.and_raise(exception)
end
- after do
- cleanup_artifacts_from_extract_archive('light')
- end
-
it 'report post import error' do
expect(restored_project_json).to eq(false)
expect(shared.errors).to include('post_import_error')
@@ -646,10 +631,6 @@ describe Gitlab::ImportExport::Project::TreeRestorer do
expect(restored_project_json).to eq(true)
end
- after do
- cleanup_artifacts_from_extract_archive('light')
- end
-
it_behaves_like 'restores project successfully',
issues: 1,
labels: 2,
@@ -678,10 +659,6 @@ describe Gitlab::ImportExport::Project::TreeRestorer do
setup_reader(reader)
end
- after do
- cleanup_artifacts_from_extract_archive('light')
- end
-
it 'handles string versions of visibility_level' do
# Project needs to be in a group for visibility level comparison
# to happen
@@ -747,10 +724,6 @@ describe Gitlab::ImportExport::Project::TreeRestorer do
expect(restored_project_json).to eq(true)
end
- after do
- cleanup_artifacts_from_extract_archive('group')
- end
-
it_behaves_like 'restores project successfully',
issues: 3,
labels: 2,
@@ -784,10 +757,6 @@ describe Gitlab::ImportExport::Project::TreeRestorer do
setup_reader(reader)
end
- after do
- cleanup_artifacts_from_extract_archive('light')
- end
-
it 'does not import any templated services' do
expect(restored_project_json).to eq(true)
@@ -835,10 +804,6 @@ describe Gitlab::ImportExport::Project::TreeRestorer do
setup_reader(reader)
end
- after do
- cleanup_artifacts_from_extract_archive('milestone-iid')
- end
-
it 'preserves the project milestone IID' do
expect_any_instance_of(Gitlab::ImportExport::Shared).not_to receive(:error)
@@ -855,10 +820,6 @@ describe Gitlab::ImportExport::Project::TreeRestorer do
setup_reader(reader)
end
- after do
- cleanup_artifacts_from_extract_archive('light')
- end
-
it 'converts empty external classification authorization labels to nil' do
project.create_import_data(data: { override_params: { external_authorization_classification_label: "" } })
@@ -1004,10 +965,6 @@ describe Gitlab::ImportExport::Project::TreeRestorer do
subject
end
- after do
- cleanup_artifacts_from_extract_archive('with_invalid_records')
- end
-
context 'when failures occur because a relation fails to be processed' do
it_behaves_like 'restores project successfully',
issues: 0,
@@ -1031,6 +988,69 @@ describe Gitlab::ImportExport::Project::TreeRestorer do
end
end
end
+
+ context 'JSON with design management data' do
+ let_it_be(:user) { create(:admin, email: 'user_1@gitlabexample.com') }
+ let_it_be(:second_user) { create(:user, email: 'user_2@gitlabexample.com') }
+ let_it_be(:project) do
+ create(:project, :builds_disabled, :issues_disabled,
+ { name: 'project', path: 'project' })
+ end
+ let(:shared) { project.import_export_shared }
+ let(:project_tree_restorer) { described_class.new(user: user, shared: shared, project: project) }
+
+ subject(:restored_project_json) { project_tree_restorer.restore }
+
+ before do
+ setup_import_export_config('designs')
+ restored_project_json
+ end
+
+ it_behaves_like 'restores project successfully', issues: 2
+
+ it 'restores project associations correctly' do
+ expect(project.designs.size).to eq(7)
+ end
+
+ describe 'restores issue associations correctly' do
+ let(:issue) { project.issues.offset(index).first }
+
+ where(:index, :design_filenames, :version_shas, :events, :author_emails) do
+ 0 | %w[chirrido3.jpg jonathan_richman.jpg mariavontrap.jpeg] | %w[27702d08f5ee021ae938737f84e8fe7c38599e85 9358d1bac8ff300d3d2597adaa2572a20f7f8703 e1a4a501bcb42f291f84e5d04c8f927821542fb6] | %w[creation creation creation modification modification deletion] | %w[user_1@gitlabexample.com user_1@gitlabexample.com user_2@gitlabexample.com]
+ 1 | ['1 (1).jpeg', '2099743.jpg', 'a screenshot (1).jpg', 'chirrido3.jpg'] | %w[73f871b4c8c1d65c62c460635e023179fb53abc4 8587e78ab6bda3bc820a9f014c3be4a21ad4fcc8 c9b5f067f3e892122a4b12b0a25a8089192f3ac8] | %w[creation creation creation creation modification] | %w[user_1@gitlabexample.com user_2@gitlabexample.com user_2@gitlabexample.com]
+ end
+
+ with_them do
+ it do
+ expect(issue.designs.pluck(:filename)).to contain_exactly(*design_filenames)
+ expect(issue.design_versions.pluck(:sha)).to contain_exactly(*version_shas)
+ expect(issue.design_versions.flat_map(&:actions).map(&:event)).to contain_exactly(*events)
+ expect(issue.design_versions.map(&:author).map(&:email)).to contain_exactly(*author_emails)
+ end
+ end
+ end
+
+ describe 'restores design version associations correctly' do
+ let(:project_designs) { project.designs.reorder(:filename, :issue_id) }
+ let(:design) { project_designs.offset(index).first }
+
+ where(:index, :version_shas) do
+ 0 | %w[73f871b4c8c1d65c62c460635e023179fb53abc4 c9b5f067f3e892122a4b12b0a25a8089192f3ac8]
+ 1 | %w[73f871b4c8c1d65c62c460635e023179fb53abc4]
+ 2 | %w[c9b5f067f3e892122a4b12b0a25a8089192f3ac8]
+ 3 | %w[27702d08f5ee021ae938737f84e8fe7c38599e85 9358d1bac8ff300d3d2597adaa2572a20f7f8703 e1a4a501bcb42f291f84e5d04c8f927821542fb6]
+ 4 | %w[8587e78ab6bda3bc820a9f014c3be4a21ad4fcc8]
+ 5 | %w[27702d08f5ee021ae938737f84e8fe7c38599e85 e1a4a501bcb42f291f84e5d04c8f927821542fb6]
+ 6 | %w[27702d08f5ee021ae938737f84e8fe7c38599e85]
+ end
+
+ with_them do
+ it do
+ expect(design.versions.pluck(:sha)).to contain_exactly(*version_shas)
+ end
+ end
+ end
+ end
end
context 'enable ndjson import' do
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 8adc360026d..b9bfe253f10 100644
--- a/spec/lib/gitlab/import_export/project/tree_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/project/tree_saver_spec.rb
@@ -168,6 +168,28 @@ describe Gitlab::ImportExport::Project::TreeSaver do
it 'has issue resource label events' do
expect(subject.first['resource_label_events']).not_to be_empty
end
+
+ it 'saves the issue designs correctly' do
+ expect(subject.first['designs'].size).to eq(1)
+ end
+
+ it 'saves the issue design notes correctly' do
+ expect(subject.first['designs'].first['notes']).not_to be_empty
+ end
+
+ it 'saves the issue design versions correctly' do
+ issue_json = subject.first
+ actions = issue_json['design_versions'].flat_map { |v| v['actions'] }
+
+ expect(issue_json['design_versions'].size).to eq(2)
+ issue_json['design_versions'].each do |version|
+ expect(version['author_id']).to be_kind_of(Integer)
+ end
+ expect(actions.size).to eq(2)
+ actions.each do |action|
+ expect(action['design']).to be_present
+ end
+ end
end
context 'with ci_pipelines' do
@@ -442,6 +464,9 @@ describe Gitlab::ImportExport::Project::TreeSaver do
board = create(:board, project: project, name: 'TestBoard')
create(:list, board: board, position: 0, label: project_label)
+ design = create(:design, :with_file, versions_count: 2, issue: issue)
+ create(:diff_note_on_design, noteable: design, project: project, author: user)
+
project
end
end
diff --git a/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb
index 0b58a75220d..8fe419da450 100644
--- a/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb
@@ -64,7 +64,7 @@ describe Gitlab::ImportExport::RelationTreeRestorer do
shared_examples 'logging of relations creation' do
context 'when log_import_export_relation_creation feature flag is enabled' do
before do
- stub_feature_flags(log_import_export_relation_creation: { enabled: true, thing: group })
+ stub_feature_flags(log_import_export_relation_creation: group)
end
it 'logs top-level relation creation' do
@@ -79,7 +79,7 @@ describe Gitlab::ImportExport::RelationTreeRestorer do
context 'when log_import_export_relation_creation feature flag is disabled' do
before do
- stub_feature_flags(log_import_export_relation_creation: { enabled: false, thing: group })
+ stub_feature_flags(log_import_export_relation_creation: false)
end
it 'does not log top-level relation creation' do
@@ -126,14 +126,6 @@ describe Gitlab::ImportExport::RelationTreeRestorer do
let(:path) { 'spec/fixtures/lib/gitlab/import_export/complex/tree' }
let(:relation_reader) { Gitlab::ImportExport::JSON::NdjsonReader.new(path) }
- before :all do
- extract_archive('spec/fixtures/lib/gitlab/import_export/complex', 'tree.tar.gz')
- end
-
- after :all do
- cleanup_artifacts_from_extract_archive('complex')
- end
-
it_behaves_like 'import project successfully'
end
end
@@ -156,7 +148,7 @@ describe Gitlab::ImportExport::RelationTreeRestorer do
let(:reader) do
Gitlab::ImportExport::Reader.new(
shared: shared,
- config: Gitlab::ImportExport::Config.new(config: Gitlab::ImportExport.group_config_file).to_h
+ config: Gitlab::ImportExport::Config.new(config: Gitlab::ImportExport.legacy_group_config_file).to_h
)
end
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index 88d7fdaef36..c29a85ce624 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -480,6 +480,7 @@ Service:
- pipeline_events
- job_events
- comment_on_event_enabled
+- comment_detail
- category
- default
- wiki_page_events
@@ -487,6 +488,7 @@ Service:
- confidential_note_events
- deployment_events
- description
+- inherit_from_id
ProjectHook:
- id
- url
diff --git a/spec/lib/gitlab/instrumentation_helper_spec.rb b/spec/lib/gitlab/instrumentation_helper_spec.rb
index 858fa044a52..fdb842dac0f 100644
--- a/spec/lib/gitlab/instrumentation_helper_spec.rb
+++ b/spec/lib/gitlab/instrumentation_helper_spec.rb
@@ -49,12 +49,12 @@ describe Gitlab::InstrumentationHelper do
describe '.queue_duration_for_job' do
where(:enqueued_at, :created_at, :time_now, :expected_duration) do
"2019-06-01T00:00:00.000+0000" | nil | "2019-06-01T02:00:00.000+0000" | 2.hours.to_f
- "2019-06-01T02:00:00.000+0000" | nil | "2019-06-01T02:00:00.001+0000" | 0.0
+ "2019-06-01T02:00:00.000+0000" | nil | "2019-06-01T02:00:00.001+0000" | 0.001
"2019-06-01T02:00:00.000+0000" | "2019-05-01T02:00:00.000+0000" | "2019-06-01T02:00:01.000+0000" | 1
- nil | "2019-06-01T02:00:00.000+0000" | "2019-06-01T02:00:00.001+0000" | 0.0
+ nil | "2019-06-01T02:00:00.000+0000" | "2019-06-01T02:00:00.001+0000" | 0.001
nil | nil | "2019-06-01T02:00:00.001+0000" | nil
"2019-06-01T02:00:00.000+0200" | nil | "2019-06-01T02:00:00.000-0200" | 4.hours.to_f
- 1571825569.998168 | nil | "2019-10-23T12:13:16.000+0200" | 26.00
+ 1571825569.998168 | nil | "2019-10-23T12:13:16.000+0200" | 26.001832
1571825569 | nil | "2019-10-23T12:13:16.000+0200" | 27
"invalid_date" | nil | "2019-10-23T12:13:16.000+0200" | nil
"" | nil | "2019-10-23T12:13:16.000+0200" | nil
diff --git a/spec/lib/gitlab/jira_import/base_importer_spec.rb b/spec/lib/gitlab/jira_import/base_importer_spec.rb
index f22efcb8743..ecaf3def589 100644
--- a/spec/lib/gitlab/jira_import/base_importer_spec.rb
+++ b/spec/lib/gitlab/jira_import/base_importer_spec.rb
@@ -3,12 +3,17 @@
require 'spec_helper'
describe Gitlab::JiraImport::BaseImporter do
+ include JiraServiceHelper
+
let(:project) { create(:project) }
describe 'with any inheriting class' do
- context 'when feature flag disabled' do
+ context 'when an error is returned from the project validation' do
before do
stub_feature_flags(jira_issue_import: false)
+
+ allow(project).to receive(:validate_jira_import_settings!)
+ .and_raise(Projects::ImportService::Error, 'Jira import feature is disabled.')
end
it 'raises exception' do
@@ -16,20 +21,17 @@ describe Gitlab::JiraImport::BaseImporter do
end
end
- context 'when feature flag enabled' do
+ context 'when project validation is ok' do
+ let!(:jira_service) { create(:jira_service, project: project) }
+
before do
stub_feature_flags(jira_issue_import: true)
- end
+ stub_jira_service_test
- context 'when Jira service was not setup' do
- it 'raises exception' do
- expect { described_class.new(project) }.to raise_error(Projects::ImportService::Error, 'Jira integration not configured.')
- end
+ allow(project).to receive(:validate_jira_import_settings!)
end
context 'when Jira service exists' do
- let!(:jira_service) { create(:jira_service, project: project) }
-
context 'when Jira import data is not present' do
it 'raises exception' do
expect { described_class.new(project) }.to raise_error(Projects::ImportService::Error, 'Unable to find Jira project to import data from.')
diff --git a/spec/lib/gitlab/jira_import/handle_labels_service_spec.rb b/spec/lib/gitlab/jira_import/handle_labels_service_spec.rb
new file mode 100644
index 00000000000..0eeff180575
--- /dev/null
+++ b/spec/lib/gitlab/jira_import/handle_labels_service_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::JiraImport::HandleLabelsService do
+ describe '#execute' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+
+ let_it_be(:project_label) { create(:label, project: project, title: 'bug') }
+ let_it_be(:other_project_label) { create(:label, title: 'feature') }
+ let_it_be(:group_label) { create(:group_label, group: group, title: 'dev') }
+ let(:jira_labels) { %w(bug feature dev group::new) }
+
+ subject { described_class.new(project, jira_labels).execute }
+
+ context 'when some provided jira labels are missing' do
+ def created_labels
+ project.labels.reorder(id: :desc).first(2)
+ end
+
+ it 'creates the missing labels on the project level' do
+ expect { subject }.to change { Label.count }.from(3).to(5)
+
+ expect(created_labels.map(&:title)).to match_array(%w(feature group::new))
+ end
+
+ it 'returns the id of all labels matching the title' do
+ expect(subject).to match_array([project_label.id, group_label.id] + created_labels.map(&:id))
+ end
+ end
+
+ context 'when no provided jira labels are missing' do
+ let(:jira_labels) { %w(bug dev) }
+
+ it 'does not create any new labels' do
+ expect { subject }.not_to change { Label.count }.from(3)
+ end
+
+ it 'returns the id of all labels matching the title' do
+ expect(subject).to match_array([project_label.id, group_label.id])
+ end
+ end
+
+ context 'when no labels are provided' do
+ let(:jira_labels) { [] }
+
+ it 'does not create any new labels' do
+ expect { subject }.not_to change { Label.count }.from(3)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/jira_import/issue_serializer_spec.rb b/spec/lib/gitlab/jira_import/issue_serializer_spec.rb
index 808ed6ee2fa..ce38a1234cf 100644
--- a/spec/lib/gitlab/jira_import/issue_serializer_spec.rb
+++ b/spec/lib/gitlab/jira_import/issue_serializer_spec.rb
@@ -4,7 +4,12 @@ require 'spec_helper'
describe Gitlab::JiraImport::IssueSerializer do
describe '#execute' do
- let_it_be(:project) { create(:project) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:project_label) { create(:label, project: project, title: 'bug') }
+ let_it_be(:other_project_label) { create(:label, project: project, title: 'feature') }
+ let_it_be(:group_label) { create(:group_label, group: group, title: 'dev') }
+ let_it_be(:current_user) { create(:user) }
let(:iid) { 5 }
let(:key) { 'PROJECT-5' }
@@ -12,28 +17,21 @@ describe Gitlab::JiraImport::IssueSerializer do
let(:description) { 'basic description' }
let(:created_at) { '2020-01-01 20:00:00' }
let(:updated_at) { '2020-01-10 20:00:00' }
- let(:assignee) { double(displayName: 'Solver') }
+ let(:assignee) { double(attrs: { 'displayName' => 'Solver', 'emailAddress' => 'assignee@example.com' }) }
+ let(:reporter) { double(attrs: { 'displayName' => 'Reporter', 'emailAddress' => 'reporter@example.com' }) }
let(:jira_status) { 'new' }
let(:parent_field) do
{ 'key' => 'FOO-2', 'id' => '1050', 'fields' => { 'summary' => 'parent issue FOO' } }
end
- let(:issue_type_field) { { 'name' => 'Task' } }
- let(:fix_versions_field) { [{ 'name' => '1.0' }, { 'name' => '1.1' }] }
let(:priority_field) { { 'name' => 'Medium' } }
- let(:labels_field) { %w(bug backend) }
- let(:environment_field) { 'staging' }
- let(:duedate_field) { '2020-03-01' }
+ let(:labels_field) { %w(bug dev backend frontend) }
let(:fields) do
{
'parent' => parent_field,
- 'issuetype' => issue_type_field,
- 'fixVersions' => fix_versions_field,
'priority' => priority_field,
- 'labels' => labels_field,
- 'environment' => environment_field,
- 'duedate' => duedate_field
+ 'labels' => labels_field
}
end
@@ -46,7 +44,7 @@ describe Gitlab::JiraImport::IssueSerializer do
created: created_at,
updated: updated_at,
assignee: assignee,
- reporter: double(displayName: 'Reporter'),
+ reporter: reporter,
status: double(statusCategory: { 'key' => jira_status }),
fields: fields
)
@@ -54,27 +52,18 @@ describe Gitlab::JiraImport::IssueSerializer do
let(:params) { { iid: iid } }
- subject { described_class.new(project, jira_issue, params).execute }
+ subject { described_class.new(project, jira_issue, current_user.id, params).execute }
let(:expected_description) do
<<~MD
- *Created by: Reporter*
-
- *Assigned to: Solver*
-
basic description
---
**Issue metadata**
- - Issue type: Task
- Priority: Medium
- - Labels: bug, backend
- - Environment: staging
- - Due date: 2020-03-01
- Parent issue: [FOO-2] parent issue FOO
- - Fix versions: 1.0, 1.1
MD
end
@@ -88,55 +77,102 @@ describe Gitlab::JiraImport::IssueSerializer do
state_id: 1,
updated_at: updated_at,
created_at: created_at,
- author_id: project.creator_id
+ author_id: current_user.id,
+ assignee_ids: nil,
+ label_ids: [project_label.id, group_label.id] + Label.reorder(id: :asc).last(2).pluck(:id)
)
end
- context 'when some metadata fields are missing' do
- let(:assignee) { nil }
- let(:parent_field) { nil }
- let(:fix_versions_field) { [] }
- let(:labels_field) { [] }
- let(:environment_field) { nil }
- let(:duedate_field) { '2020-03-01' }
+ it 'creates a hash for valid issue' do
+ expect(Issue.new(subject)).to be_valid
+ end
+
+ context 'labels' do
+ it 'creates all missing labels (on project level)' do
+ expect { subject }.to change { Label.count }.from(3).to(5)
+
+ expect(Label.find_by(title: 'frontend').project).to eq(project)
+ expect(Label.find_by(title: 'backend').project).to eq(project)
+ end
+
+ context 'when there are no new labels' do
+ let(:labels_field) { %w(bug dev) }
- it 'skips the missing fields' do
- expected_description = <<~MD
- *Created by: Reporter*
+ it 'assigns the labels to the Issue hash' do
+ expect(subject[:label_ids]).to match_array([project_label.id, group_label.id])
+ end
- basic description
+ it 'does not create new labels' do
+ expect { subject }.not_to change { Label.count }.from(3)
+ end
+ end
+ end
- ---
+ context 'author' do
+ context 'when reporter maps to a valid GitLab user' do
+ let!(:user) { create(:user, email: 'reporter@example.com') }
- **Issue metadata**
+ it 'sets the issue author to the mapped user' do
+ project.add_developer(user)
- - Issue type: Task
- - Priority: Medium
- - Due date: 2020-03-01
- MD
+ expect(subject[:author_id]).to eq(user.id)
+ end
+ end
- expect(subject[:description]).to eq(expected_description.strip)
+ context 'when reporter does not map to a valid Gitlab user' do
+ it 'defaults the issue author to project creator' do
+ expect(subject[:author_id]).to eq(current_user.id)
+ end
+ end
+
+ context 'when reporter field is empty' do
+ let(:reporter) { nil }
+
+ it 'defaults the issue author to project creator' do
+ expect(subject[:author_id]).to eq(current_user.id)
+ end
+ end
+
+ context 'when reporter field is missing email address' do
+ let(:reporter) { double(attrs: { 'displayName' => 'Reporter' }) }
+
+ it 'defaults the issue author to project creator' do
+ expect(subject[:author_id]).to eq(current_user.id)
+ end
end
end
- context 'when all metadata fields are missing' do
- let(:assignee) { nil }
- let(:parent_field) { nil }
- let(:issue_type_field) { nil }
- let(:fix_versions_field) { [] }
- let(:priority_field) { nil }
- let(:labels_field) { [] }
- let(:environment_field) { nil }
- let(:duedate_field) { nil }
+ context 'assignee' do
+ context 'when assignee maps to a valid GitLab user' do
+ let!(:user) { create(:user, email: 'assignee@example.com') }
+
+ it 'sets the issue assignees to the mapped user' do
+ project.add_developer(user)
- it 'skips the whole metadata secction' do
- expected_description = <<~MD
- *Created by: Reporter*
+ expect(subject[:assignee_ids]).to eq([user.id])
+ end
+ end
+
+ context 'when assignee does not map to a valid GitLab user' do
+ it 'leaves the assignee empty' do
+ expect(subject[:assignee_ids]).to be_nil
+ end
+ end
+
+ context 'when assginee field is empty' do
+ let(:assignee) { nil }
+
+ it 'leaves the assignee empty' do
+ expect(subject[:assignee_ids]).to be_nil
+ end
+ end
- basic description
- MD
+ context 'when assginee field is missing email address' do
+ let(:assignee) { double(attrs: { 'displayName' => 'Reporter' }) }
- expect(subject[:description]).to eq(expected_description.strip)
+ it 'leaves the assignee empty' do
+ expect(subject[:assignee_ids]).to be_nil
+ end
end
end
end
diff --git a/spec/lib/gitlab/jira_import/issues_importer_spec.rb b/spec/lib/gitlab/jira_import/issues_importer_spec.rb
index 8e16fd3e978..6cf06c20e19 100644
--- a/spec/lib/gitlab/jira_import/issues_importer_spec.rb
+++ b/spec/lib/gitlab/jira_import/issues_importer_spec.rb
@@ -3,15 +3,19 @@
require 'spec_helper'
describe Gitlab::JiraImport::IssuesImporter do
+ include JiraServiceHelper
+
let_it_be(:user) { create(:user) }
+ let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
- let_it_be(:jira_import) { create(:jira_import_state, project: project) }
+ let_it_be(:jira_import) { create(:jira_import_state, project: project, user: current_user) }
let_it_be(:jira_service) { create(:jira_service, project: project) }
subject { described_class.new(project) }
before do
stub_feature_flags(jira_issue_import: true)
+ stub_jira_service_test
end
describe '#imported_items_cache_key' do
@@ -36,8 +40,16 @@ describe Gitlab::JiraImport::IssuesImporter do
context 'with results returned' do
JiraIssue = Struct.new(:id)
- let_it_be(:jira_issue1) { JiraIssue.new(1) }
- let_it_be(:jira_issue2) { JiraIssue.new(2) }
+ let_it_be(:jira_issues) { [JiraIssue.new(1), JiraIssue.new(2)] }
+
+ def mock_issue_serializer(count)
+ serializer = instance_double(Gitlab::JiraImport::IssueSerializer, execute: { key: 'data' })
+
+ count.times do |i|
+ expect(Gitlab::JiraImport::IssueSerializer).to receive(:new)
+ .with(project, jira_issues[i], current_user.id, { iid: i + 1 }).and_return(serializer)
+ end
+ end
context 'when single page of results is returned' do
before do
@@ -45,13 +57,11 @@ describe Gitlab::JiraImport::IssuesImporter do
end
it 'schedules 2 import jobs' do
- expect(subject).to receive(:fetch_issues).and_return([jira_issue1, jira_issue2])
+ expect(subject).to receive(:fetch_issues).with(0).and_return([jira_issues[0], jira_issues[1]])
expect(Gitlab::JiraImport::ImportIssueWorker).to receive(:perform_async).twice
expect(Gitlab::Cache::Import::Caching).to receive(:set_add).twice.and_call_original
expect(Gitlab::Cache::Import::Caching).to receive(:set_includes?).twice.and_call_original
- allow_next_instance_of(Gitlab::JiraImport::IssueSerializer) do |instance|
- allow(instance).to receive(:execute).and_return({ key: 'data' })
- end
+ mock_issue_serializer(2)
job_waiter = subject.execute
@@ -66,13 +76,11 @@ describe Gitlab::JiraImport::IssuesImporter do
end
it 'schedules 3 import jobs' do
- expect(subject).to receive(:fetch_issues).with(0).and_return([jira_issue1, jira_issue2])
+ expect(subject).to receive(:fetch_issues).with(0).and_return([jira_issues[0], jira_issues[1]])
expect(Gitlab::JiraImport::ImportIssueWorker).to receive(:perform_async).twice.times
expect(Gitlab::Cache::Import::Caching).to receive(:set_add).twice.times.and_call_original
expect(Gitlab::Cache::Import::Caching).to receive(:set_includes?).twice.times.and_call_original
- allow_next_instance_of(Gitlab::JiraImport::IssueSerializer) do |instance|
- allow(instance).to receive(:execute).and_return({ key: 'data' })
- end
+ mock_issue_serializer(2)
job_waiter = subject.execute
@@ -87,13 +95,11 @@ describe Gitlab::JiraImport::IssuesImporter do
end
it 'schedules 2 import jobs' do
- expect(subject).to receive(:fetch_issues).with(0).and_return([jira_issue1, jira_issue1])
+ expect(subject).to receive(:fetch_issues).with(0).and_return([jira_issues[0], jira_issues[0]])
expect(Gitlab::JiraImport::ImportIssueWorker).to receive(:perform_async).once
expect(Gitlab::Cache::Import::Caching).to receive(:set_add).once.and_call_original
expect(Gitlab::Cache::Import::Caching).to receive(:set_includes?).twice.times.and_call_original
- allow_next_instance_of(Gitlab::JiraImport::IssueSerializer) do |instance|
- allow(instance).to receive(:execute).and_return({ key: 'data' })
- end
+ mock_issue_serializer(1)
job_waiter = subject.execute
diff --git a/spec/lib/gitlab/jira_import/labels_importer_spec.rb b/spec/lib/gitlab/jira_import/labels_importer_spec.rb
index 3eb4666a74f..67eb541d376 100644
--- a/spec/lib/gitlab/jira_import/labels_importer_spec.rb
+++ b/spec/lib/gitlab/jira_import/labels_importer_spec.rb
@@ -3,35 +3,100 @@
require 'spec_helper'
describe Gitlab::JiraImport::LabelsImporter do
+ include JiraServiceHelper
+
let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
let_it_be(:jira_service) { create(:jira_service, project: project) }
- subject { described_class.new(project).execute }
+ let(:importer) { described_class.new(project) }
+
+ subject { importer.execute }
before do
stub_feature_flags(jira_issue_import: true)
+ stub_const('Gitlab::JiraImport::LabelsImporter::MAX_LABELS', 2)
end
describe '#execute', :clean_gitlab_redis_cache do
+ before do
+ stub_jira_service_test
+ end
+
context 'when label is missing from jira import' do
let_it_be(:no_label_jira_import) { create(:jira_import_state, label: nil, project: project) }
it 'raises error' do
- expect { subject }.to raise_error(Projects::ImportService::Error, 'Failed to find import label for jira import.')
+ expect { subject }.to raise_error(Projects::ImportService::Error, 'Failed to find import label for Jira import.')
end
end
- context 'when label exists' do
- let_it_be(:label) { create(:label) }
+ context 'when jira import label exists' do
+ let_it_be(:label) { create(:label) }
let_it_be(:jira_import_with_label) { create(:jira_import_state, label: label, project: project) }
+ let_it_be(:issue_label) { create(:label, project: project, title: 'bug') }
+
+ let(:jira_labels_1) { { "maxResults" => 2, "startAt" => 0, "total" => 3, "isLast" => false, "values" => %w(backend bug) } }
+ let(:jira_labels_2) { { "maxResults" => 2, "startAt" => 2, "total" => 3, "isLast" => true, "values" => %w(feature) } }
+
+ context 'when labels are returned from jira' do
+ before do
+ client = double
+ expect(importer).to receive(:client).twice.and_return(client)
+ allow(client).to receive(:get).twice.and_return(jira_labels_1, jira_labels_2)
+ end
+
+ it 'caches import label' do
+ expect(Gitlab::Cache::Import::Caching.read(Gitlab::JiraImport.import_label_cache_key(project.id))).to be nil
+
+ subject
+
+ expect(Gitlab::JiraImport.get_import_label_id(project.id).to_i).to eq(label.id)
+ end
+
+ it 'calls Gitlab::JiraImport::HandleLabelsService' do
+ expect(Gitlab::JiraImport::HandleLabelsService).to receive(:new).with(project, %w(backend bug)).and_return(double(execute: [1, 2]))
+ expect(Gitlab::JiraImport::HandleLabelsService).to receive(:new).with(project, %w(feature)).and_return(double(execute: [3]))
+
+ subject
+ end
+ end
+
+ context 'when there are no labels to be handled' do
+ shared_examples 'no labels handling' do
+ it 'does not call Gitlab::JiraImport::HandleLabelsService' do
+ expect(Gitlab::JiraImport::HandleLabelsService).not_to receive(:new)
+
+ subject
+ end
+ end
+
+ let(:jira_labels) { { "maxResults" => 2, "startAt" => 0, "total" => 3, "values" => [] } }
+
+ before do
+ client = double
+ expect(importer).to receive(:client).and_return(client)
+ allow(client).to receive(:get).and_return(jira_labels)
+ end
+
+ context 'when the labels field is empty' do
+ let(:jira_labels) { { "maxResults" => 2, "startAt" => 0, "isLast" => true, "total" => 3, "values" => [] } }
+
+ it_behaves_like 'no labels handling'
+ end
+
+ context 'when the labels field is missing' do
+ let(:jira_labels) { { "maxResults" => 2, "startAt" => 0, "isLast" => true, "total" => 3 } }
- it 'caches import label' do
- expect(Gitlab::Cache::Import::Caching.read(Gitlab::JiraImport.import_label_cache_key(project.id))).to be nil
+ it_behaves_like 'no labels handling'
+ end
- subject
+ context 'when the isLast argument is missing' do
+ let(:jira_labels) { { "maxResults" => 2, "startAt" => 0, "total" => 3, "values" => %w(bug dev) } }
- expect(Gitlab::JiraImport.get_import_label_id(project.id).to_i).to eq(label.id)
+ it_behaves_like 'no labels handling'
+ end
end
end
end
diff --git a/spec/lib/gitlab/jira_import/metadata_collector_spec.rb b/spec/lib/gitlab/jira_import/metadata_collector_spec.rb
new file mode 100644
index 00000000000..af479810df0
--- /dev/null
+++ b/spec/lib/gitlab/jira_import/metadata_collector_spec.rb
@@ -0,0 +1,178 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::JiraImport::MetadataCollector do
+ describe '#execute' do
+ let(:key) { 'PROJECT-5' }
+ let(:summary) { 'some title' }
+ let(:description) { 'basic description' }
+ let(:created_at) { '2020-01-01 20:00:00' }
+ let(:updated_at) { '2020-01-10 20:00:00' }
+ let(:jira_status) { 'new' }
+
+ let(:parent_field) do
+ { 'key' => 'FOO-2', 'id' => '1050', 'fields' => { 'summary' => 'parent issue FOO' } }
+ end
+ let(:issue_type_field) { { 'name' => 'Task' } }
+ let(:fix_versions_field) { [{ 'name' => '1.0' }, { 'name' => '1.1' }] }
+ let(:priority_field) { { 'name' => 'Medium' } }
+ let(:environment_field) { 'staging' }
+ let(:duedate_field) { '2020-03-01' }
+
+ let(:fields) do
+ {
+ 'parent' => parent_field,
+ 'issuetype' => issue_type_field,
+ 'fixVersions' => fix_versions_field,
+ 'priority' => priority_field,
+ 'environment' => environment_field,
+ 'duedate' => duedate_field
+ }
+ end
+ let(:jira_issue) do
+ double(
+ id: '1234',
+ key: key,
+ summary: summary,
+ description: description,
+ created: created_at,
+ updated: updated_at,
+ status: double(statusCategory: { 'key' => jira_status }),
+ fields: fields
+ )
+ end
+
+ subject { described_class.new(jira_issue).execute }
+
+ context 'when all metadata fields are present' do
+ it 'writes all fields' do
+ expected_result = <<~MD
+ ---
+
+ **Issue metadata**
+
+ - Issue type: Task
+ - Priority: Medium
+ - Environment: staging
+ - Due date: 2020-03-01
+ - Parent issue: [FOO-2] parent issue FOO
+ - Fix versions: 1.0, 1.1
+ MD
+
+ expect(subject.strip).to eq(expected_result.strip)
+ end
+ end
+
+ context 'when some fields are in incorrect format' do
+ let(:parent_field) { nil }
+ let(:fix_versions_field) { [] }
+ let(:priority_field) { nil }
+ let(:environment_field) { nil }
+ let(:duedate_field) { nil }
+
+ context 'when fixVersions field is not an array' do
+ let(:fix_versions_field) { { 'title' => '1.0', 'name' => '1.1' } }
+
+ it 'skips these fields' do
+ expected_result = <<~MD
+ ---
+
+ **Issue metadata**
+
+ - Issue type: Task
+ MD
+
+ expect(subject.strip).to eq(expected_result.strip)
+ end
+ end
+
+ context 'when a fixVersions element is in incorrect format' do
+ let(:fix_versions_field) { [{ 'title' => '1.0' }, { 'name' => '1.1' }] }
+
+ it 'skips the element' do
+ expected_result = <<~MD
+ ---
+
+ **Issue metadata**
+
+ - Issue type: Task
+ - Fix versions: 1.1
+ MD
+
+ expect(subject.strip).to eq(expected_result.strip)
+ end
+ end
+
+ context 'when a parent field has incorrectly formatted summary' do
+ let(:parent_field) do
+ { 'key' => 'FOO-2', 'id' => '1050', 'other_field' => { 'summary' => 'parent issue FOO' } }
+ end
+
+ it 'skips the summary' do
+ expected_result = <<~MD
+ ---
+
+ **Issue metadata**
+
+ - Issue type: Task
+ - Parent issue: [FOO-2]
+ MD
+
+ expect(subject.strip).to eq(expected_result.strip)
+ end
+ end
+
+ context 'when a parent field is missing the key' do
+ let(:parent_field) do
+ { 'not_key' => 'FOO-2', 'id' => '1050', 'other_field' => { 'summary' => 'parent issue FOO' } }
+ end
+
+ it 'skips the field' do
+ expected_result = <<~MD
+ ---
+
+ **Issue metadata**
+
+ - Issue type: Task
+ MD
+
+ expect(subject.strip).to eq(expected_result.strip)
+ end
+ end
+ end
+
+ context 'when some metadata fields are missing' do
+ let(:parent_field) { nil }
+ let(:fix_versions_field) { [] }
+ let(:environment_field) { nil }
+
+ it 'skips the missing fields' do
+ expected_result = <<~MD
+ ---
+
+ **Issue metadata**
+
+ - Issue type: Task
+ - Priority: Medium
+ - Due date: 2020-03-01
+ MD
+
+ expect(subject.strip).to eq(expected_result.strip)
+ end
+ end
+
+ context 'when all metadata fields are missing' do
+ let(:parent_field) { nil }
+ let(:issue_type_field) { nil }
+ let(:fix_versions_field) { [] }
+ let(:priority_field) { nil }
+ let(:environment_field) { nil }
+ let(:duedate_field) { nil }
+
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/jira_import/user_mapper_spec.rb b/spec/lib/gitlab/jira_import/user_mapper_spec.rb
new file mode 100644
index 00000000000..c8c8bd3c5b0
--- /dev/null
+++ b/spec/lib/gitlab/jira_import/user_mapper_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::JiraImport::UserMapper do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:user) { create(:user, email: 'user@example.com') }
+ let_it_be(:email) { create(:email, user: user, email: 'second_email@example.com', confirmed_at: nil) }
+
+ let(:jira_user) { { 'acountId' => '1a2b', 'emailAddress' => 'user@example.com' } }
+
+ describe '#execute' do
+ subject { described_class.new(project, jira_user).execute }
+
+ context 'when jira_user is nil' do
+ let(:jira_user) { nil }
+
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'when Gitlab user is not found by email' do
+ let(:jira_user) { { 'acountId' => '1a2b', 'emailAddress' => 'other@example.com' } }
+
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'when jira_user emailAddress is nil' do
+ let(:jira_user) { { 'acountId' => '1a2b', 'emailAddress' => nil } }
+
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'when jira_user emailAddress key is missing' do
+ let(:jira_user) { { 'acountId' => '1a2b' } }
+
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'when found user is not a project member' do
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'when found user is a project member' do
+ it 'returns the found user' do
+ project.add_developer(user)
+
+ expect(subject).to eq(user)
+ end
+ end
+
+ context 'when user found by unconfirmd secondary address is a project member' do
+ let(:jira_user) { { 'acountId' => '1a2b', 'emailAddress' => 'second_email@example.com' } }
+
+ it 'returns the found user' do
+ project.add_developer(user)
+
+ expect(subject).to eq(user)
+ end
+ end
+
+ context 'when user is a group member' do
+ it 'returns the found user' do
+ group.add_developer(user)
+
+ expect(subject).to eq(user)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/json_logger_spec.rb b/spec/lib/gitlab/json_logger_spec.rb
index 5d544198c40..41dafc84ef2 100644
--- a/spec/lib/gitlab/json_logger_spec.rb
+++ b/spec/lib/gitlab/json_logger_spec.rb
@@ -14,7 +14,7 @@ describe Gitlab::JsonLogger do
it 'formats strings' do
output = subject.format_message('INFO', now, 'test', 'Hello world')
- data = JSON.parse(output)
+ data = Gitlab::Json.parse(output)
expect(data['severity']).to eq('INFO')
expect(data['time']).to eq(now.utc.iso8601(3))
@@ -24,7 +24,7 @@ describe Gitlab::JsonLogger do
it 'formats hashes' do
output = subject.format_message('INFO', now, 'test', { hello: 1 })
- data = JSON.parse(output)
+ data = Gitlab::Json.parse(output)
expect(data['severity']).to eq('INFO')
expect(data['time']).to eq(now.utc.iso8601(3))
diff --git a/spec/lib/gitlab/json_spec.rb b/spec/lib/gitlab/json_spec.rb
index 5186ab041da..ee7c98a5a54 100644
--- a/spec/lib/gitlab/json_spec.rb
+++ b/spec/lib/gitlab/json_spec.rb
@@ -3,47 +3,151 @@
require "spec_helper"
RSpec.describe Gitlab::Json do
+ before do
+ stub_feature_flags(json_wrapper_legacy_mode: true)
+ end
+
describe ".parse" do
- it "parses an object" do
- expect(subject.parse('{ "foo": "bar" }')).to eq({ "foo" => "bar" })
- end
+ context "legacy_mode is disabled by default" do
+ it "parses an object" do
+ expect(subject.parse('{ "foo": "bar" }')).to eq({ "foo" => "bar" })
+ end
- it "parses an array" do
- expect(subject.parse('[{ "foo": "bar" }]')).to eq([{ "foo" => "bar" }])
- end
+ it "parses an array" do
+ expect(subject.parse('[{ "foo": "bar" }]')).to eq([{ "foo" => "bar" }])
+ end
- it "raises an error on a string" do
- expect { subject.parse('"foo"') }.to raise_error(JSON::ParserError)
+ it "parses a string" do
+ expect(subject.parse('"foo"', legacy_mode: false)).to eq("foo")
+ end
+
+ it "parses a true bool" do
+ expect(subject.parse("true", legacy_mode: false)).to be(true)
+ end
+
+ it "parses a false bool" do
+ expect(subject.parse("false", legacy_mode: false)).to be(false)
+ end
end
- it "raises an error on a true bool" do
- expect { subject.parse("true") }.to raise_error(JSON::ParserError)
+ context "legacy_mode is enabled" do
+ it "parses an object" do
+ expect(subject.parse('{ "foo": "bar" }', legacy_mode: true)).to eq({ "foo" => "bar" })
+ end
+
+ it "parses an array" do
+ expect(subject.parse('[{ "foo": "bar" }]', legacy_mode: true)).to eq([{ "foo" => "bar" }])
+ end
+
+ it "raises an error on a string" do
+ expect { subject.parse('"foo"', legacy_mode: true) }.to raise_error(JSON::ParserError)
+ end
+
+ it "raises an error on a true bool" do
+ expect { subject.parse("true", legacy_mode: true) }.to raise_error(JSON::ParserError)
+ end
+
+ it "raises an error on a false bool" do
+ expect { subject.parse("false", legacy_mode: true) }.to raise_error(JSON::ParserError)
+ end
end
- it "raises an error on a false bool" do
- expect { subject.parse("false") }.to raise_error(JSON::ParserError)
+ context "feature flag is disabled" do
+ before do
+ stub_feature_flags(json_wrapper_legacy_mode: false)
+ end
+
+ it "parses an object" do
+ expect(subject.parse('{ "foo": "bar" }', legacy_mode: true)).to eq({ "foo" => "bar" })
+ end
+
+ it "parses an array" do
+ expect(subject.parse('[{ "foo": "bar" }]', legacy_mode: true)).to eq([{ "foo" => "bar" }])
+ end
+
+ it "parses a string" do
+ expect(subject.parse('"foo"', legacy_mode: true)).to eq("foo")
+ end
+
+ it "parses a true bool" do
+ expect(subject.parse("true", legacy_mode: true)).to be(true)
+ end
+
+ it "parses a false bool" do
+ expect(subject.parse("false", legacy_mode: true)).to be(false)
+ end
end
end
describe ".parse!" do
- it "parses an object" do
- expect(subject.parse!('{ "foo": "bar" }')).to eq({ "foo" => "bar" })
- end
+ context "legacy_mode is disabled by default" do
+ it "parses an object" do
+ expect(subject.parse!('{ "foo": "bar" }')).to eq({ "foo" => "bar" })
+ end
- it "parses an array" do
- expect(subject.parse!('[{ "foo": "bar" }]')).to eq([{ "foo" => "bar" }])
- end
+ it "parses an array" do
+ expect(subject.parse!('[{ "foo": "bar" }]')).to eq([{ "foo" => "bar" }])
+ end
+
+ it "parses a string" do
+ expect(subject.parse!('"foo"', legacy_mode: false)).to eq("foo")
+ end
- it "raises an error on a string" do
- expect { subject.parse!('"foo"') }.to raise_error(JSON::ParserError)
+ it "parses a true bool" do
+ expect(subject.parse!("true", legacy_mode: false)).to be(true)
+ end
+
+ it "parses a false bool" do
+ expect(subject.parse!("false", legacy_mode: false)).to be(false)
+ end
end
- it "raises an error on a true bool" do
- expect { subject.parse!("true") }.to raise_error(JSON::ParserError)
+ context "legacy_mode is enabled" do
+ it "parses an object" do
+ expect(subject.parse!('{ "foo": "bar" }', legacy_mode: true)).to eq({ "foo" => "bar" })
+ end
+
+ it "parses an array" do
+ expect(subject.parse!('[{ "foo": "bar" }]', legacy_mode: true)).to eq([{ "foo" => "bar" }])
+ end
+
+ it "raises an error on a string" do
+ expect { subject.parse!('"foo"', legacy_mode: true) }.to raise_error(JSON::ParserError)
+ end
+
+ it "raises an error on a true bool" do
+ expect { subject.parse!("true", legacy_mode: true) }.to raise_error(JSON::ParserError)
+ end
+
+ it "raises an error on a false bool" do
+ expect { subject.parse!("false", legacy_mode: true) }.to raise_error(JSON::ParserError)
+ end
end
- it "raises an error on a false bool" do
- expect { subject.parse!("false") }.to raise_error(JSON::ParserError)
+ context "feature flag is disabled" do
+ before do
+ stub_feature_flags(json_wrapper_legacy_mode: false)
+ end
+
+ it "parses an object" do
+ expect(subject.parse!('{ "foo": "bar" }', legacy_mode: true)).to eq({ "foo" => "bar" })
+ end
+
+ it "parses an array" do
+ expect(subject.parse!('[{ "foo": "bar" }]', legacy_mode: true)).to eq([{ "foo" => "bar" }])
+ end
+
+ it "parses a string" do
+ expect(subject.parse!('"foo"', legacy_mode: true)).to eq("foo")
+ end
+
+ it "parses a true bool" do
+ expect(subject.parse!("true", legacy_mode: true)).to be(true)
+ end
+
+ it "parses a false bool" do
+ expect(subject.parse!("false", legacy_mode: true)).to be(false)
+ end
end
end
diff --git a/spec/lib/gitlab/kubernetes/helm/api_spec.rb b/spec/lib/gitlab/kubernetes/helm/api_spec.rb
index 8147990ecc3..1f925fd45af 100644
--- a/spec/lib/gitlab/kubernetes/helm/api_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/api_spec.rb
@@ -92,7 +92,6 @@ describe Gitlab::Kubernetes::Helm::API do
allow(client).to receive(:get_config_map).and_return(nil)
allow(client).to receive(:create_config_map).and_return(nil)
allow(client).to receive(:create_service_account).and_return(nil)
- allow(client).to receive(:create_cluster_role_binding).and_return(nil)
allow(client).to receive(:delete_pod).and_return(nil)
allow(namespace).to receive(:ensure_exists!).once
end
@@ -136,7 +135,7 @@ describe Gitlab::Kubernetes::Helm::API do
context 'without a service account' do
it 'does not create a service account on kubeclient' do
expect(client).not_to receive(:create_service_account)
- expect(client).not_to receive(:create_cluster_role_binding)
+ expect(client).not_to receive(:update_cluster_role_binding)
subject.install(command)
end
@@ -160,15 +159,14 @@ describe Gitlab::Kubernetes::Helm::API do
)
end
- context 'service account and cluster role binding does not exist' do
+ context 'service account does not exist' do
before do
expect(client).to receive(:get_service_account).with('tiller', 'gitlab-managed-apps').and_raise(Kubeclient::ResourceNotFoundError.new(404, 'Not found', nil))
- expect(client).to receive(:get_cluster_role_binding).with('tiller-admin').and_raise(Kubeclient::ResourceNotFoundError.new(404, 'Not found', nil))
end
it 'creates a service account, followed the cluster role binding on kubeclient' do
expect(client).to receive(:create_service_account).with(service_account_resource).once.ordered
- expect(client).to receive(:create_cluster_role_binding).with(cluster_role_binding_resource).once.ordered
+ expect(client).to receive(:update_cluster_role_binding).with(cluster_role_binding_resource).once.ordered
subject.install(command)
end
@@ -177,21 +175,6 @@ describe Gitlab::Kubernetes::Helm::API do
context 'service account already exists' do
before do
expect(client).to receive(:get_service_account).with('tiller', 'gitlab-managed-apps').and_return(service_account_resource)
- expect(client).to receive(:get_cluster_role_binding).with('tiller-admin').and_raise(Kubeclient::ResourceNotFoundError.new(404, 'Not found', nil))
- end
-
- it 'updates the service account, followed by creating the cluster role binding' do
- expect(client).to receive(:update_service_account).with(service_account_resource).once.ordered
- expect(client).to receive(:create_cluster_role_binding).with(cluster_role_binding_resource).once.ordered
-
- subject.install(command)
- end
- end
-
- context 'service account and cluster role binding already exists' do
- before do
- expect(client).to receive(:get_service_account).with('tiller', 'gitlab-managed-apps').and_return(service_account_resource)
- expect(client).to receive(:get_cluster_role_binding).with('tiller-admin').and_return(cluster_role_binding_resource)
end
it 'updates the service account, followed by creating the cluster role binding' do
@@ -216,7 +199,7 @@ describe Gitlab::Kubernetes::Helm::API do
context 'legacy abac cluster' do
it 'does not create a service account on kubeclient' do
expect(client).not_to receive(:create_service_account)
- expect(client).not_to receive(:create_cluster_role_binding)
+ expect(client).not_to receive(:update_cluster_role_binding)
subject.install(command)
end
diff --git a/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb
index a11a9d08503..2a4a911cf38 100644
--- a/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb
@@ -3,6 +3,10 @@
require 'spec_helper'
describe Gitlab::Kubernetes::Helm::BaseCommand do
+ subject(:base_command) do
+ test_class.new(rbac)
+ end
+
let(:application) { create(:clusters_applications_helm) }
let(:rbac) { false }
@@ -30,87 +34,17 @@ describe Gitlab::Kubernetes::Helm::BaseCommand do
end
end
- let(:base_command) do
- test_class.new(rbac)
- end
-
- subject { base_command }
-
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) { '' }
end
- describe '#pod_resource' do
- subject { base_command.pod_resource }
-
- it 'returns a kubeclient resoure with pod content for application' do
- is_expected.to be_an_instance_of ::Kubeclient::Resource
- end
-
- context 'when rbac is true' do
- let(:rbac) { true }
-
- it 'also returns a kubeclient resource' do
- is_expected.to be_an_instance_of ::Kubeclient::Resource
- end
- end
- end
-
describe '#pod_name' do
subject { base_command.pod_name }
it { is_expected.to eq('install-test-class-name') }
end
- describe '#service_account_resource' do
- let(:resource) do
- Kubeclient::Resource.new(metadata: { name: 'tiller', namespace: 'gitlab-managed-apps' })
- end
-
- subject { base_command.service_account_resource }
-
- context 'rbac is enabled' do
- let(:rbac) { true }
-
- it 'generates a Kubeclient resource for the tiller ServiceAccount' do
- is_expected.to eq(resource)
- end
- end
-
- context 'rbac is not enabled' do
- let(:rbac) { false }
-
- it 'generates nothing' do
- is_expected.to be_nil
- end
- end
- end
-
- describe '#cluster_role_binding_resource' do
- let(:resource) do
- Kubeclient::Resource.new(
- metadata: { name: 'tiller-admin' },
- roleRef: { apiGroup: 'rbac.authorization.k8s.io', kind: 'ClusterRole', name: 'cluster-admin' },
- subjects: [{ kind: 'ServiceAccount', name: 'tiller', namespace: 'gitlab-managed-apps' }]
- )
- end
-
- subject { base_command.cluster_role_binding_resource }
-
- context 'rbac is enabled' do
- let(:rbac) { true }
-
- it 'generates a Kubeclient resource for the ClusterRoleBinding for tiller' do
- is_expected.to eq(resource)
- end
- end
-
- context 'rbac is not enabled' do
- let(:rbac) { false }
-
- it 'generates nothing' do
- is_expected.to be_nil
- end
- end
+ it_behaves_like 'helm command' do
+ let(:command) { base_command }
end
end
diff --git a/spec/lib/gitlab/kubernetes/helm/delete_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/delete_command_spec.rb
index 82e15864687..95d60c18d56 100644
--- a/spec/lib/gitlab/kubernetes/helm/delete_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/delete_command_spec.rb
@@ -3,14 +3,13 @@
require 'spec_helper'
describe Gitlab::Kubernetes::Helm::DeleteCommand do
+ subject(:delete_command) { described_class.new(name: app_name, rbac: rbac, files: files) }
+
let(:app_name) { 'app-name' }
let(:rbac) { true }
let(:files) { {} }
- let(:delete_command) { described_class.new(name: app_name, rbac: rbac, files: files) }
-
- subject { delete_command }
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
export HELM_HOST="localhost:44134"
@@ -26,7 +25,7 @@ describe Gitlab::Kubernetes::Helm::DeleteCommand do
stub_feature_flags(managed_apps_local_tiller: false)
end
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
helm init --upgrade
@@ -48,7 +47,7 @@ describe Gitlab::Kubernetes::Helm::DeleteCommand do
EOS
end
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
helm init --upgrade
@@ -67,29 +66,19 @@ describe Gitlab::Kubernetes::Helm::DeleteCommand do
end
end
- describe '#pod_resource' do
- subject { delete_command.pod_resource }
-
- context 'rbac is enabled' do
- let(:rbac) { true }
-
- it 'generates a pod that uses the tiller serviceAccountName' do
- expect(subject.spec.serviceAccountName).to eq('tiller')
- end
- end
-
- context 'rbac is not enabled' do
- let(:rbac) { false }
-
- it 'generates a pod that uses the default serviceAccountName' do
- expect(subject.spec.serviceAcccountName).to be_nil
- end
- end
- end
-
describe '#pod_name' do
subject { delete_command.pod_name }
it { is_expected.to eq('uninstall-app-name') }
end
+
+ it_behaves_like 'helm command' do
+ let(:command) { delete_command }
+ end
+
+ describe '#delete_command' do
+ it 'deletes the release' do
+ expect(subject.delete_command).to eq('helm delete --purge app-name')
+ end
+ end
end
diff --git a/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb
index 13021a08f9f..05d9b63d12b 100644
--- a/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb
@@ -3,25 +3,24 @@
require 'spec_helper'
describe Gitlab::Kubernetes::Helm::InitCommand do
+ subject(:init_command) { described_class.new(name: application.name, files: files, rbac: rbac) }
+
let(:application) { create(:clusters_applications_helm) }
let(:rbac) { false }
let(:files) { {} }
- let(:init_command) { described_class.new(name: application.name, files: files, rbac: rbac) }
- let(:commands) do
- <<~EOS
- helm init --tiller-tls --tiller-tls-verify --tls-ca-cert /data/helm/helm/config/ca.pem --tiller-tls-cert /data/helm/helm/config/cert.pem --tiller-tls-key /data/helm/helm/config/key.pem
- EOS
+ it_behaves_like 'helm command generator' do
+ let(:commands) do
+ <<~EOS
+ helm init --tiller-tls --tiller-tls-verify --tls-ca-cert /data/helm/helm/config/ca.pem --tiller-tls-cert /data/helm/helm/config/cert.pem --tiller-tls-key /data/helm/helm/config/key.pem
+ EOS
+ end
end
- subject { init_command }
-
- it_behaves_like 'helm commands'
-
context 'on a rbac-enabled cluster' do
let(:rbac) { true }
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
helm init --tiller-tls --tiller-tls-verify --tls-ca-cert /data/helm/helm/config/ca.pem --tiller-tls-cert /data/helm/helm/config/cert.pem --tiller-tls-key /data/helm/helm/config/key.pem --service-account tiller
@@ -30,57 +29,7 @@ describe Gitlab::Kubernetes::Helm::InitCommand do
end
end
- describe '#rbac?' do
- subject { init_command.rbac? }
-
- context 'rbac is enabled' do
- let(:rbac) { true }
-
- it { is_expected.to be_truthy }
- end
-
- context 'rbac is not enabled' do
- let(:rbac) { false }
-
- it { is_expected.to be_falsey }
- end
- end
-
- describe '#config_map_resource' do
- let(:metadata) do
- {
- name: 'values-content-configuration-helm',
- namespace: 'gitlab-managed-apps',
- labels: { name: 'values-content-configuration-helm' }
- }
- end
-
- let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: files) }
-
- subject { init_command.config_map_resource }
-
- it 'returns a KubeClient resource with config map content for the application' do
- is_expected.to eq(resource)
- end
- end
-
- describe '#pod_resource' do
- subject { init_command.pod_resource }
-
- context 'rbac is enabled' do
- let(:rbac) { true }
-
- it 'generates a pod that uses the tiller serviceAccountName' do
- expect(subject.spec.serviceAccountName).to eq('tiller')
- end
- end
-
- context 'rbac is not enabled' do
- let(:rbac) { false }
-
- it 'generates a pod that uses the default serviceAccountName' do
- expect(subject.spec.serviceAcccountName).to be_nil
- end
- end
+ it_behaves_like 'helm command' do
+ let(:command) { init_command }
end
end
diff --git a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
index a5ed8f57bf3..abd29e97505 100644
--- a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
@@ -3,14 +3,7 @@
require 'spec_helper'
describe Gitlab::Kubernetes::Helm::InstallCommand do
- let(:files) { { 'ca.pem': 'some file content' } }
- let(:repository) { 'https://repository.example.com' }
- let(:rbac) { false }
- let(:version) { '1.2.3' }
- let(:preinstall) { nil }
- let(:postinstall) { nil }
-
- let(:install_command) do
+ subject(:install_command) do
described_class.new(
name: 'app-name',
chart: 'chart-name',
@@ -23,9 +16,14 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
)
end
- subject { install_command }
+ let(:files) { { 'ca.pem': 'some file content' } }
+ let(:repository) { 'https://repository.example.com' }
+ let(:rbac) { false }
+ let(:version) { '1.2.3' }
+ let(:preinstall) { nil }
+ let(:postinstall) { nil }
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
export HELM_HOST="localhost:44134"
@@ -66,7 +64,7 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
EOS
end
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
helm init --upgrade
@@ -97,7 +95,7 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
context 'when rbac is true' do
let(:rbac) { true }
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
export HELM_HOST="localhost:44134"
@@ -128,7 +126,7 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
context 'when there is a pre-install script' do
let(:preinstall) { ['/bin/date', '/bin/true'] }
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
export HELM_HOST="localhost:44134"
@@ -161,7 +159,7 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
context 'when there is a post-install script' do
let(:postinstall) { ['/bin/date', "/bin/false\n"] }
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
export HELM_HOST="localhost:44134"
@@ -194,7 +192,7 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
context 'when there is no ca.pem file' do
let(:files) { { 'file.txt': 'some content' } }
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
export HELM_HOST="localhost:44134"
@@ -225,7 +223,7 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
context 'when there is no version' do
let(:version) { nil }
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
export HELM_HOST="localhost:44134"
@@ -252,57 +250,7 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
end
end
- describe '#rbac?' do
- subject { install_command.rbac? }
-
- context 'rbac is enabled' do
- let(:rbac) { true }
-
- it { is_expected.to be_truthy }
- end
-
- context 'rbac is not enabled' do
- let(:rbac) { false }
-
- it { is_expected.to be_falsey }
- end
- end
-
- describe '#pod_resource' do
- subject { install_command.pod_resource }
-
- context 'rbac is enabled' do
- let(:rbac) { true }
-
- it 'generates a pod that uses the tiller serviceAccountName' do
- expect(subject.spec.serviceAccountName).to eq('tiller')
- end
- end
-
- context 'rbac is not enabled' do
- let(:rbac) { false }
-
- it 'generates a pod that uses the default serviceAccountName' do
- expect(subject.spec.serviceAcccountName).to be_nil
- end
- end
- end
-
- describe '#config_map_resource' do
- let(:metadata) do
- {
- name: "values-content-configuration-app-name",
- namespace: 'gitlab-managed-apps',
- labels: { name: "values-content-configuration-app-name" }
- }
- end
-
- let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: files) }
-
- subject { install_command.config_map_resource }
-
- it 'returns a KubeClient resource with config map content for the application' do
- is_expected.to eq(resource)
- end
+ it_behaves_like 'helm command' do
+ let(:command) { install_command }
end
end
diff --git a/spec/lib/gitlab/kubernetes/helm/parsers/list_v2_spec.rb b/spec/lib/gitlab/kubernetes/helm/parsers/list_v2_spec.rb
new file mode 100644
index 00000000000..0ad5dc189c0
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/helm/parsers/list_v2_spec.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+describe Gitlab::Kubernetes::Helm::Parsers::ListV2 do
+ let(:valid_file_contents) do
+ <<~EOF
+ {
+ "Next": "",
+ "Releases": [
+ {
+ "Name": "certmanager",
+ "Revision": 2,
+ "Updated": "Sun Mar 29 06:55:42 2020",
+ "Status": "DEPLOYED",
+ "Chart": "cert-manager-v0.10.1",
+ "AppVersion": "v0.10.1",
+ "Namespace": "gitlab-managed-apps"
+ },
+ {
+ "Name": "certmanager-crds",
+ "Revision": 2,
+ "Updated": "Sun Mar 29 06:55:32 2020",
+ "Status": "DEPLOYED",
+ "Chart": "cert-manager-crds-v0.2.0",
+ "AppVersion": "release-0.10",
+ "Namespace": "gitlab-managed-apps"
+ },
+ {
+ "Name": "certmanager-issuer",
+ "Revision": 1,
+ "Updated": "Tue Feb 18 10:04:04 2020",
+ "Status": "FAILED",
+ "Chart": "cert-manager-issuer-v0.1.0",
+ "AppVersion": "",
+ "Namespace": "gitlab-managed-apps"
+ },
+ {
+ "Name": "runner",
+ "Revision": 2,
+ "Updated": "Sun Mar 29 07:01:01 2020",
+ "Status": "DEPLOYED",
+ "Chart": "gitlab-runner-0.14.0",
+ "AppVersion": "12.8.0",
+ "Namespace": "gitlab-managed-apps"
+ }
+ ]
+ }
+ EOF
+ end
+
+ describe '#initialize' do
+ it 'initializes without error' do
+ expect do
+ described_class.new(valid_file_contents)
+ end.not_to raise_error
+ end
+
+ it 'raises an error on invalid JSON' do
+ expect do
+ described_class.new('')
+ end.to raise_error(described_class::ParserError)
+ end
+ end
+
+ describe '#releases' do
+ subject(:list_v2) { described_class.new(valid_file_contents) }
+
+ it 'returns list of releases' do
+ expect(list_v2.releases).to match([
+ a_hash_including('Name' => 'certmanager', 'Status' => 'DEPLOYED'),
+ a_hash_including('Name' => 'certmanager-crds', 'Status' => 'DEPLOYED'),
+ a_hash_including('Name' => 'certmanager-issuer', 'Status' => 'FAILED'),
+ a_hash_including('Name' => 'runner', 'Status' => 'DEPLOYED')
+ ])
+ end
+
+ context 'empty Releases' do
+ let(:valid_file_contents) { '{}' }
+
+ it 'returns an empty array' do
+ expect(list_v2.releases).to eq([])
+ end
+ end
+
+ context 'invalid Releases' do
+ let(:invalid_file_contents) do
+ '{ "Releases" : ["a", "b"] }'
+ end
+
+ subject(:list_v2) { described_class.new(invalid_file_contents) }
+
+ it 'raises an error' do
+ expect do
+ list_v2.releases
+ end.to raise_error(described_class::ParserError, 'Invalid format for Releases')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/helm/patch_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/patch_command_spec.rb
index e69570f5371..eee842fa7d6 100644
--- a/spec/lib/gitlab/kubernetes/helm/patch_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/patch_command_spec.rb
@@ -33,7 +33,7 @@ describe Gitlab::Kubernetes::Helm::PatchCommand do
EOS
end
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
helm init --upgrade
@@ -57,7 +57,7 @@ describe Gitlab::Kubernetes::Helm::PatchCommand do
end
end
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
export HELM_HOST="localhost:44134"
@@ -83,7 +83,7 @@ describe Gitlab::Kubernetes::Helm::PatchCommand do
context 'when rbac is true' do
let(:rbac) { true }
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
export HELM_HOST="localhost:44134"
@@ -110,7 +110,7 @@ describe Gitlab::Kubernetes::Helm::PatchCommand do
context 'when there is no ca.pem file' do
let(:files) { { 'file.txt': 'some content' } }
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
export HELM_HOST="localhost:44134"
@@ -134,69 +134,19 @@ describe Gitlab::Kubernetes::Helm::PatchCommand do
end
end
- describe '#pod_name' do
- subject { patch_command.pod_name }
-
- it { is_expected.to eq 'install-app-name' }
- end
-
context 'when there is no version' do
let(:version) { nil }
it { expect { patch_command }.to raise_error(ArgumentError, 'version is required') }
end
- describe '#rbac?' do
- subject { patch_command.rbac? }
-
- context 'rbac is enabled' do
- let(:rbac) { true }
-
- it { is_expected.to be_truthy }
- end
-
- context 'rbac is not enabled' do
- let(:rbac) { false }
-
- it { is_expected.to be_falsey }
- end
- end
-
- describe '#pod_resource' do
- subject { patch_command.pod_resource }
-
- context 'rbac is enabled' do
- let(:rbac) { true }
-
- it 'generates a pod that uses the tiller serviceAccountName' do
- expect(subject.spec.serviceAccountName).to eq('tiller')
- end
- end
-
- context 'rbac is not enabled' do
- let(:rbac) { false }
+ describe '#pod_name' do
+ subject { patch_command.pod_name }
- it 'generates a pod that uses the default serviceAccountName' do
- expect(subject.spec.serviceAcccountName).to be_nil
- end
- end
+ it { is_expected.to eq 'install-app-name' }
end
- describe '#config_map_resource' do
- let(:metadata) do
- {
- name: "values-content-configuration-app-name",
- namespace: 'gitlab-managed-apps',
- labels: { name: "values-content-configuration-app-name" }
- }
- end
-
- let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: files) }
-
- subject { patch_command.config_map_resource }
-
- it 'returns a KubeClient resource with config map content for the application' do
- is_expected.to eq(resource)
- end
+ it_behaves_like 'helm command' do
+ let(:command) { patch_command }
end
end
diff --git a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
index 3c62219a9a5..ea32ac96213 100644
--- a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
@@ -32,7 +32,7 @@ describe Gitlab::Kubernetes::Helm::Pod do
it 'generates the appropriate specifications for the container' do
container = subject.generate.spec.containers.first
expect(container.name).to eq('helm')
- expect(container.image).to eq('registry.gitlab.com/gitlab-org/cluster-integration/helm-install-image/releases/2.16.3-kube-1.13.12')
+ expect(container.image).to eq('registry.gitlab.com/gitlab-org/cluster-integration/helm-install-image/releases/2.16.6-kube-1.13.12')
expect(container.env.count).to eq(3)
expect(container.env.map(&:name)).to match_array([:HELM_VERSION, :TILLER_NAMESPACE, :COMMAND_SCRIPT])
expect(container.command).to match_array(["/bin/sh"])
diff --git a/spec/lib/gitlab/kubernetes/helm/reset_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/reset_command_spec.rb
index 2a89b04723d..981bb4e4abf 100644
--- a/spec/lib/gitlab/kubernetes/helm/reset_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/reset_command_spec.rb
@@ -3,14 +3,13 @@
require 'spec_helper'
describe Gitlab::Kubernetes::Helm::ResetCommand do
+ subject(:reset_command) { described_class.new(name: name, rbac: rbac, files: files) }
+
let(:rbac) { true }
let(:name) { 'helm' }
let(:files) { {} }
- let(:reset_command) { described_class.new(name: name, rbac: rbac, files: files) }
-
- subject { reset_command }
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS
helm reset
@@ -23,7 +22,7 @@ describe Gitlab::Kubernetes::Helm::ResetCommand do
context 'when there is a ca.pem file' do
let(:files) { { 'ca.pem': 'some file content' } }
- it_behaves_like 'helm commands' do
+ it_behaves_like 'helm command generator' do
let(:commands) do
<<~EOS1.squish + "\n" + <<~EOS2
helm reset
@@ -39,29 +38,13 @@ describe Gitlab::Kubernetes::Helm::ResetCommand do
end
end
- describe '#pod_resource' do
- subject { reset_command.pod_resource }
-
- context 'rbac is enabled' do
- let(:rbac) { true }
-
- it 'generates a pod that uses the tiller serviceAccountName' do
- expect(subject.spec.serviceAccountName).to eq('tiller')
- end
- end
-
- context 'rbac is not enabled' do
- let(:rbac) { false }
-
- it 'generates a pod that uses the default serviceAccountName' do
- expect(subject.spec.serviceAcccountName).to be_nil
- end
- end
- end
-
describe '#pod_name' do
subject { reset_command.pod_name }
it { is_expected.to eq('uninstall-helm') }
end
+
+ it_behaves_like 'helm command' do
+ let(:command) { reset_command }
+ end
end
diff --git a/spec/lib/gitlab/kubernetes/kube_client_spec.rb b/spec/lib/gitlab/kubernetes/kube_client_spec.rb
index 1959fbca33b..32597aa4f5a 100644
--- a/spec/lib/gitlab/kubernetes/kube_client_spec.rb
+++ b/spec/lib/gitlab/kubernetes/kube_client_spec.rb
@@ -64,6 +64,45 @@ describe Gitlab::Kubernetes::KubeClient do
end
end
+ describe '.graceful_request' do
+ context 'successful' do
+ before do
+ allow(client).to receive(:foo).and_return(true)
+ end
+
+ it 'returns connected status and foo response' do
+ result = described_class.graceful_request(1) { client.foo }
+
+ expect(result).to eq({ status: :connected, response: true })
+ end
+ end
+
+ context 'errored' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:error, :error_status) do
+ SocketError | :unreachable
+ OpenSSL::X509::CertificateError | :authentication_failure
+ StandardError | :unknown_failure
+ Kubeclient::HttpError.new(408, "timed out", nil) | :unreachable
+ Kubeclient::HttpError.new(408, "timeout", nil) | :unreachable
+ Kubeclient::HttpError.new(408, "", nil) | :authentication_failure
+ end
+
+ with_them do
+ before do
+ allow(client).to receive(:foo).and_raise(error)
+ end
+
+ it 'returns error status' do
+ result = described_class.graceful_request(1) { client.foo }
+
+ expect(result).to eq({ status: error_status })
+ end
+ end
+ end
+ end
+
describe '#initialize' do
shared_examples 'local address' do
it 'blocks local addresses' do
@@ -174,10 +213,39 @@ describe Gitlab::Kubernetes::KubeClient do
end
end
+ describe '#networking_client' do
+ subject { client.networking_client }
+
+ it_behaves_like 'a Kubeclient'
+
+ it 'has the networking API group endpoint' do
+ expect(subject.api_endpoint.to_s).to match(%r{\/apis\/networking.k8s.io\Z})
+ end
+
+ it 'has the api_version' do
+ expect(subject.instance_variable_get(:@api_version)).to eq('v1')
+ end
+ end
+
+ describe '#metrics_client' do
+ subject { client.metrics_client }
+
+ it_behaves_like 'a Kubeclient'
+
+ it 'has the metrics API group endpoint' do
+ expect(subject.api_endpoint.to_s).to match(%r{\/apis\/metrics.k8s.io\Z})
+ end
+
+ it 'has the api_version' do
+ expect(subject.instance_variable_get(:@api_version)).to eq('v1beta1')
+ end
+ end
+
describe 'core API' do
let(:core_client) { client.core_client }
[
+ :get_nodes,
:get_pods,
:get_secrets,
:get_config_map,
@@ -220,8 +288,6 @@ describe Gitlab::Kubernetes::KubeClient do
:create_role,
:get_role,
:update_role,
- :create_cluster_role_binding,
- :get_cluster_role_binding,
:update_cluster_role_binding
].each do |method|
describe "##{method}" do
@@ -290,6 +356,30 @@ describe Gitlab::Kubernetes::KubeClient do
end
end
+ describe 'networking API group' do
+ let(:networking_client) { client.networking_client }
+
+ [
+ :create_network_policy,
+ :get_network_policies,
+ :update_network_policy,
+ :delete_network_policy
+ ].each do |method|
+ describe "##{method}" do
+ include_examples 'redirection not allowed', method
+ include_examples 'dns rebinding not allowed', method
+
+ it 'delegates to the networking client' do
+ expect(client).to delegate_method(method).to(:networking_client)
+ end
+
+ it 'responds to the method' do
+ expect(client).to respond_to method
+ end
+ end
+ end
+ end
+
describe 'non-entity methods' do
it 'does not proxy for non-entity methods' do
expect(client).not_to respond_to :proxy_url
@@ -316,6 +406,16 @@ describe Gitlab::Kubernetes::KubeClient do
end
end
+ shared_examples 'create_or_update method using put' do
+ let(:update_method) { "update_#{resource_type}" }
+
+ it 'calls the update method' do
+ expect(client).to receive(update_method).with(resource)
+
+ subject
+ end
+ end
+
shared_examples 'create_or_update method' do
let(:get_method) { "get_#{resource_type}" }
let(:update_method) { "update_#{resource_type}" }
@@ -355,7 +455,7 @@ describe Gitlab::Kubernetes::KubeClient do
subject { client.create_or_update_cluster_role_binding(resource) }
- it_behaves_like 'create_or_update method'
+ it_behaves_like 'create_or_update method using put'
end
describe '#create_or_update_role_binding' do
@@ -367,7 +467,7 @@ describe Gitlab::Kubernetes::KubeClient do
subject { client.create_or_update_role_binding(resource) }
- it_behaves_like 'create_or_update method'
+ it_behaves_like 'create_or_update method using put'
end
describe '#create_or_update_service_account' do
diff --git a/spec/lib/gitlab/kubernetes/network_policy_spec.rb b/spec/lib/gitlab/kubernetes/network_policy_spec.rb
new file mode 100644
index 00000000000..f23d215a9a1
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/network_policy_spec.rb
@@ -0,0 +1,224 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Kubernetes::NetworkPolicy do
+ let(:policy) do
+ described_class.new(
+ name: name,
+ namespace: namespace,
+ creation_timestamp: '2020-04-14T00:08:30Z',
+ pod_selector: pod_selector,
+ policy_types: %w(Ingress Egress),
+ ingress: ingress,
+ egress: egress
+ )
+ end
+
+ let(:name) { 'example-name' }
+ let(:namespace) { 'example-namespace' }
+ let(:pod_selector) { { matchLabels: { role: 'db' } } }
+
+ let(:ingress) do
+ [
+ {
+ from: [
+ { namespaceSelector: { matchLabels: { project: 'myproject' } } }
+ ]
+ }
+ ]
+ end
+
+ let(:egress) do
+ [
+ {
+ ports: [{ port: 5978 }]
+ }
+ ]
+ end
+
+ describe '.from_yaml' do
+ let(:manifest) do
+ <<-POLICY
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: example-name
+ namespace: example-namespace
+spec:
+ podSelector:
+ matchLabels:
+ role: db
+ policyTypes:
+ - Ingress
+ ingress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ project: myproject
+ POLICY
+ end
+ let(:resource) do
+ ::Kubeclient::Resource.new(
+ metadata: { name: name, namespace: namespace },
+ spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
+ )
+ end
+
+ subject { Gitlab::Kubernetes::NetworkPolicy.from_yaml(manifest)&.generate }
+
+ it { is_expected.to eq(resource) }
+
+ context 'with nil manifest' do
+ let(:manifest) { nil }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'with invalid manifest' do
+ let(:manifest) { "\tfoo: bar" }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'with manifest without metadata' do
+ let(:manifest) do
+ <<-POLICY
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+spec:
+ podSelector:
+ matchLabels:
+ role: db
+ policyTypes:
+ - Ingress
+ ingress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ project: myproject
+ POLICY
+ end
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'with manifest without spec' do
+ let(:manifest) do
+ <<-POLICY
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: example-name
+ namespace: example-namespace
+ POLICY
+ end
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'with disallowed class' do
+ let(:manifest) do
+ <<-POLICY
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: example-name
+ namespace: example-namespace
+ creationTimestamp: 2020-04-14T00:08:30Z
+spec:
+ podSelector:
+ matchLabels:
+ role: db
+ policyTypes:
+ - Ingress
+ ingress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ project: myproject
+ POLICY
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '.from_resource' do
+ let(:resource) do
+ ::Kubeclient::Resource.new(
+ metadata: { name: name, namespace: namespace, creationTimestamp: '2020-04-14T00:08:30Z', resourceVersion: '4990' },
+ spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
+ )
+ end
+ let(:generated_resource) do
+ ::Kubeclient::Resource.new(
+ metadata: { name: name, namespace: namespace },
+ spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
+ )
+ end
+
+ subject { Gitlab::Kubernetes::NetworkPolicy.from_resource(resource)&.generate }
+
+ it { is_expected.to eq(generated_resource) }
+
+ context 'with nil resource' do
+ let(:resource) { nil }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'with resource without metadata' do
+ let(:resource) do
+ ::Kubeclient::Resource.new(
+ spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
+ )
+ end
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'with resource without spec' do
+ let(:resource) do
+ ::Kubeclient::Resource.new(
+ metadata: { name: name, namespace: namespace, uid: '128cf288-7de4-11ea-aceb-42010a800089', resourceVersion: '4990' }
+ )
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '#generate' do
+ let(:resource) do
+ ::Kubeclient::Resource.new(
+ metadata: { name: name, namespace: namespace },
+ spec: { podSelector: pod_selector, policyTypes: %w(Ingress Egress), ingress: ingress, egress: egress }
+ )
+ end
+
+ subject { policy.generate }
+
+ it { is_expected.to eq(resource) }
+ end
+
+ describe '#as_json' do
+ let(:json_policy) do
+ {
+ name: name,
+ namespace: namespace,
+ creation_timestamp: '2020-04-14T00:08:30Z',
+ manifest: YAML.dump(
+ {
+ metadata: { name: name, namespace: namespace },
+ spec: { podSelector: pod_selector, policyTypes: %w(Ingress Egress), ingress: ingress, egress: egress }
+ }.deep_stringify_keys
+ )
+ }
+ end
+
+ subject { policy.as_json }
+
+ it { is_expected.to eq(json_policy) }
+ end
+end
diff --git a/spec/lib/gitlab/legacy_github_import/importer_spec.rb b/spec/lib/gitlab/legacy_github_import/importer_spec.rb
index af0bffa91a5..8cc3fd8efbd 100644
--- a/spec/lib/gitlab/legacy_github_import/importer_spec.rb
+++ b/spec/lib/gitlab/legacy_github_import/importer_spec.rb
@@ -294,6 +294,7 @@ describe Gitlab::LegacyGithubImport::Importer do
it_behaves_like 'Gitlab::LegacyGithubImport::Importer#execute' do
let(:expected_not_called) { [:import_releases, [:import_comments, :pull_requests]] }
end
+
it_behaves_like 'Gitlab::LegacyGithubImport::Importer#execute an error occurs'
it_behaves_like 'Gitlab::LegacyGithubImport unit-testing'
diff --git a/spec/lib/gitlab/logging/cloudflare_helper_spec.rb b/spec/lib/gitlab/logging/cloudflare_helper_spec.rb
new file mode 100644
index 00000000000..2b73fb7bc1c
--- /dev/null
+++ b/spec/lib/gitlab/logging/cloudflare_helper_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Logging::CloudflareHelper do
+ let(:helper) do
+ Class.new do
+ include Gitlab::Logging::CloudflareHelper
+ end.new
+ end
+
+ describe '#store_cloudflare_headers!' do
+ let(:payload) { {} }
+ let(:env) { {} }
+ let(:request) { ActionDispatch::Request.new(env) }
+
+ before do
+ request.headers.merge!(headers)
+ end
+
+ context 'with normal headers' do
+ let(:headers) { { 'Cf-Ray' => '592f0aa22b3dea38-IAD', 'Cf-Request-Id' => SecureRandom.hex } }
+
+ it 'adds Cf-Ray-Id and Cf-Request-Id' do
+ helper.store_cloudflare_headers!(payload, request)
+
+ expect(payload[:cf_ray]).to eq(headers['Cf-Ray'])
+ expect(payload[:cf_request_id]).to eq(headers['Cf-Request-Id'])
+ end
+ end
+
+ context 'with header values with long strings' do
+ let(:headers) { { 'Cf-Ray' => SecureRandom.hex(33), 'Cf-Request-Id' => SecureRandom.hex(33) } }
+
+ it 'filters invalid header values' do
+ helper.store_cloudflare_headers!(payload, request)
+
+ expect(payload.keys).not_to include(:cf_ray, :cf_request_id)
+ end
+ end
+
+ context 'with header values with non-alphanumeric characters' do
+ let(:headers) { { 'Cf-Ray' => "Bad\u0000ray", 'Cf-Request-Id' => "Bad\u0000req" } }
+
+ it 'filters invalid header values' do
+ helper.store_cloudflare_headers!(payload, request)
+
+ expect(payload.keys).not_to include(:cf_ray, :cf_request_id)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/lograge/custom_options_spec.rb b/spec/lib/gitlab/lograge/custom_options_spec.rb
index 48d06283b7a..7ae8baa31b5 100644
--- a/spec/lib/gitlab/lograge/custom_options_spec.rb
+++ b/spec/lib/gitlab/lograge/custom_options_spec.rb
@@ -19,7 +19,13 @@ describe Gitlab::Lograge::CustomOptions do
1,
2,
'transaction_id',
- { params: params, user_id: 'test' }
+ {
+ params: params,
+ user_id: 'test',
+ cf_ray: SecureRandom.hex,
+ cf_request_id: SecureRandom.hex,
+ metadata: { 'meta.user' => 'jane.doe' }
+ }
)
end
@@ -46,5 +52,30 @@ describe Gitlab::Lograge::CustomOptions do
it 'adds the user id' do
expect(subject[:user_id]).to eq('test')
end
+
+ it 'adds Cloudflare headers' do
+ expect(subject[:cf_ray]).to eq(event.payload[:cf_ray])
+ expect(subject[:cf_request_id]).to eq(event.payload[:cf_request_id])
+ end
+
+ it 'adds the metadata' do
+ expect(subject['meta.user']).to eq('jane.doe')
+ end
+
+ context 'when metadata is missing' do
+ let(:event) do
+ ActiveSupport::Notifications::Event.new(
+ 'test',
+ 1,
+ 2,
+ 'transaction_id',
+ { params: {} }
+ )
+ end
+
+ it 'does not break' do
+ expect { subject }.not_to raise_error
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/mail_room/mail_room_spec.rb b/spec/lib/gitlab/mail_room/mail_room_spec.rb
index 5d41ee06263..4b09205a181 100644
--- a/spec/lib/gitlab/mail_room/mail_room_spec.rb
+++ b/spec/lib/gitlab/mail_room/mail_room_spec.rb
@@ -13,7 +13,8 @@ describe Gitlab::MailRoom do
start_tls: false,
mailbox: 'inbox',
idle_timeout: 60,
- log_path: Rails.root.join('log', 'mail_room_json.log').to_s
+ log_path: Rails.root.join('log', 'mail_room_json.log').to_s,
+ expunge_deleted: false
}
end
diff --git a/spec/lib/gitlab/metrics/background_transaction_spec.rb b/spec/lib/gitlab/metrics/background_transaction_spec.rb
index d87d2c839ad..84f405d7369 100644
--- a/spec/lib/gitlab/metrics/background_transaction_spec.rb
+++ b/spec/lib/gitlab/metrics/background_transaction_spec.rb
@@ -7,12 +7,6 @@ describe Gitlab::Metrics::BackgroundTransaction do
subject { described_class.new(test_worker_class) }
- describe '#action' do
- it 'returns transaction action name' do
- expect(subject.action).to eq('TestWorker#perform')
- end
- end
-
describe '#label' do
it 'returns labels based on class name' do
expect(subject.labels).to eq(controller: 'TestWorker', action: 'perform')
diff --git a/spec/lib/gitlab/metrics/dashboard/stages/grafana_formatter_spec.rb b/spec/lib/gitlab/metrics/dashboard/stages/grafana_formatter_spec.rb
index e41004bb57e..5d4bd4512e3 100644
--- a/spec/lib/gitlab/metrics/dashboard/stages/grafana_formatter_spec.rb
+++ b/spec/lib/gitlab/metrics/dashboard/stages/grafana_formatter_spec.rb
@@ -9,9 +9,9 @@ describe Gitlab::Metrics::Dashboard::Stages::GrafanaFormatter do
let_it_be(:project) { create(:project, namespace: namespace, name: 'bar') }
describe '#transform!' do
- let(:grafana_dashboard) { JSON.parse(fixture_file('grafana/simplified_dashboard_response.json'), symbolize_names: true) }
- let(:datasource) { JSON.parse(fixture_file('grafana/datasource_response.json'), symbolize_names: true) }
- let(:expected_dashboard) { JSON.parse(fixture_file('grafana/expected_grafana_embed.json'), symbolize_names: true) }
+ let(:grafana_dashboard) { Gitlab::Json.parse(fixture_file('grafana/simplified_dashboard_response.json'), symbolize_names: true) }
+ let(:datasource) { Gitlab::Json.parse(fixture_file('grafana/datasource_response.json'), symbolize_names: true) }
+ let(:expected_dashboard) { Gitlab::Json.parse(fixture_file('grafana/expected_grafana_embed.json'), symbolize_names: true) }
subject(:dashboard) { described_class.new(project, {}, params).transform! }
diff --git a/spec/lib/gitlab/metrics/dashboard/url_spec.rb b/spec/lib/gitlab/metrics/dashboard/url_spec.rb
index 9ccd1c06d6b..75f9f99c8a6 100644
--- a/spec/lib/gitlab/metrics/dashboard/url_spec.rb
+++ b/spec/lib/gitlab/metrics/dashboard/url_spec.rb
@@ -3,17 +3,21 @@
require 'spec_helper'
describe Gitlab::Metrics::Dashboard::Url do
+ include Gitlab::Routing.url_helpers
+
describe '#metrics_regex' do
- let(:url) do
- Gitlab::Routing.url_helpers.metrics_namespace_project_environment_url(
+ let(:url_params) do
+ [
'foo',
'bar',
1,
- start: '2019-08-02T05:43:09.000Z',
- dashboard: 'config/prometheus/common_metrics.yml',
- group: 'awesome group',
- anchor: 'title'
- )
+ {
+ start: '2019-08-02T05:43:09.000Z',
+ dashboard: 'config/prometheus/common_metrics.yml',
+ group: 'awesome group',
+ anchor: 'title'
+ }
+ ]
end
let(:expected_params) do
@@ -29,12 +33,22 @@ describe Gitlab::Metrics::Dashboard::Url do
subject { described_class.metrics_regex }
- it_behaves_like 'regex which matches url when expected'
+ context 'for 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_dashboard route' do
+ let(:url) { metrics_dashboard_namespace_project_environment_url(*url_params) }
+
+ it_behaves_like 'regex which matches url when expected'
+ end
end
describe '#grafana_regex' do
let(:url) do
- Gitlab::Routing.url_helpers.namespace_project_grafana_api_metrics_dashboard_url(
+ namespace_project_grafana_api_metrics_dashboard_url(
'foo',
'bar',
start: '2019-08-02T05:43:09.000Z',
diff --git a/spec/lib/gitlab/metrics/exporter/sidekiq_exporter_spec.rb b/spec/lib/gitlab/metrics/exporter/sidekiq_exporter_spec.rb
index a415b6407d5..0b820fdbde9 100644
--- a/spec/lib/gitlab/metrics/exporter/sidekiq_exporter_spec.rb
+++ b/spec/lib/gitlab/metrics/exporter/sidekiq_exporter_spec.rb
@@ -53,7 +53,7 @@ describe Gitlab::Metrics::Exporter::SidekiqExporter do
.with(
class: described_class.to_s,
message: 'Cannot start sidekiq_exporter',
- exception: anything)
+ 'exception.message' => anything)
exporter.start
end
diff --git a/spec/lib/gitlab/metrics/method_call_spec.rb b/spec/lib/gitlab/metrics/method_call_spec.rb
index 3b5e04e2df5..229db67ec88 100644
--- a/spec/lib/gitlab/metrics/method_call_spec.rb
+++ b/spec/lib/gitlab/metrics/method_call_spec.rb
@@ -76,25 +76,6 @@ describe Gitlab::Metrics::MethodCall do
end
end
- describe '#to_metric' do
- it 'returns a Metric instance' do
- expect(method_call).to receive(:real_time).and_return(4.0001).twice
- expect(method_call).to receive(:cpu_time).and_return(3.0001)
-
- method_call.measure { 'foo' }
- metric = method_call.to_metric
-
- expect(metric).to be_an_instance_of(Gitlab::Metrics::Metric)
- expect(metric.series).to eq('rails_method_calls')
-
- expect(metric.values[:duration]).to eq(4000)
- expect(metric.values[:cpu_duration]).to eq(3000)
- expect(metric.values[:call_count]).to be_an(Integer)
-
- expect(metric.tags).to eq({ method: 'Foo#bar' })
- end
- end
-
describe '#above_threshold?' do
before do
allow(Gitlab::Metrics).to receive(:method_call_threshold).and_return(100)
diff --git a/spec/lib/gitlab/metrics/metric_spec.rb b/spec/lib/gitlab/metrics/metric_spec.rb
deleted file mode 100644
index 611b59231ba..00000000000
--- a/spec/lib/gitlab/metrics/metric_spec.rb
+++ /dev/null
@@ -1,71 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe Gitlab::Metrics::Metric do
- let(:metric) do
- described_class.new('foo', { number: 10 }, { host: 'localtoast' })
- end
-
- describe '#series' do
- subject { metric.series }
-
- it { is_expected.to eq('foo') }
- end
-
- describe '#values' do
- subject { metric.values }
-
- it { is_expected.to eq({ number: 10 }) }
- end
-
- describe '#tags' do
- subject { metric.tags }
-
- it { is_expected.to eq({ host: 'localtoast' }) }
- end
-
- describe '#type' do
- subject { metric.type }
-
- it { is_expected.to eq(:metric) }
- end
-
- describe '#event?' do
- it 'returns false for a regular metric' do
- expect(metric.event?).to eq(false)
- end
-
- it 'returns true for an event metric' do
- expect(metric).to receive(:type).and_return(:event)
-
- expect(metric.event?).to eq(true)
- end
- end
-
- describe '#to_hash' do
- it 'returns a Hash' do
- expect(metric.to_hash).to be_an_instance_of(Hash)
- end
-
- describe 'the returned Hash' do
- let(:hash) { metric.to_hash }
-
- it 'includes the series' do
- expect(hash[:series]).to eq('foo')
- end
-
- it 'includes the tags' do
- expect(hash[:tags]).to be_an_instance_of(Hash)
- end
-
- it 'includes the values' do
- expect(hash[:values]).to eq({ number: 10 })
- end
-
- it 'includes the timestamp' do
- expect(hash[:timestamp]).to be_an(Integer)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/metrics/rack_middleware_spec.rb b/spec/lib/gitlab/metrics/rack_middleware_spec.rb
index 1c1681cc5ab..dd1dbf7a1f4 100644
--- a/spec/lib/gitlab/metrics/rack_middleware_spec.rb
+++ b/spec/lib/gitlab/metrics/rack_middleware_spec.rb
@@ -10,10 +10,6 @@ describe Gitlab::Metrics::RackMiddleware do
let(:env) { { 'REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/foo' } }
describe '#call' do
- before do
- expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
- end
-
it 'tracks a transaction' do
expect(app).to receive(:call).with(env).and_return('yay')
@@ -36,26 +32,5 @@ describe Gitlab::Metrics::RackMiddleware do
it 'returns a Transaction' do
expect(transaction).to be_an_instance_of(Gitlab::Metrics::WebTransaction)
end
-
- it 'stores the request method and URI in the transaction as values' do
- expect(transaction.values[:request_method]).to eq('GET')
- expect(transaction.values[:request_uri]).to eq('/foo')
- end
-
- context "when URI includes sensitive parameters" do
- let(:env) do
- {
- 'REQUEST_METHOD' => 'GET',
- 'REQUEST_URI' => '/foo?private_token=my-token',
- 'PATH_INFO' => '/foo',
- 'QUERY_STRING' => 'private_token=my_token',
- 'action_dispatch.parameter_filter' => [:private_token]
- }
- end
-
- it 'stores the request URI with the sensitive parameters filtered' do
- expect(transaction.values[:request_uri]).to eq('/foo?private_token=[FILTERED]')
- end
- end
end
end
diff --git a/spec/lib/gitlab/metrics/samplers/database_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/database_sampler_spec.rb
new file mode 100644
index 00000000000..fdf3b5bd045
--- /dev/null
+++ b/spec/lib/gitlab/metrics/samplers/database_sampler_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Metrics::Samplers::DatabaseSampler do
+ subject { described_class.new(described_class::SAMPLING_INTERVAL_SECONDS) }
+
+ describe '#sample' do
+ before do
+ described_class::METRIC_DESCRIPTIONS.each_key do |metric|
+ allow(subject.metrics[metric]).to receive(:set)
+ end
+ end
+
+ context 'for ActiveRecord::Base' do
+ let(:labels) do
+ {
+ class: 'ActiveRecord::Base',
+ host: Gitlab::Database.config['host'],
+ port: Gitlab::Database.config['port']
+ }
+ end
+
+ context 'when the database is connected' do
+ it 'samples connection pool statistics' do
+ expect(subject.metrics[:size]).to receive(:set).with(labels, a_value >= 1)
+ expect(subject.metrics[:connections]).to receive(:set).with(labels, a_value >= 1)
+ expect(subject.metrics[:busy]).to receive(:set).with(labels, a_value >= 1)
+ expect(subject.metrics[:dead]).to receive(:set).with(labels, a_value >= 0)
+ expect(subject.metrics[:waiting]).to receive(:set).with(labels, a_value >= 0)
+
+ subject.sample
+ end
+ end
+
+ context 'when the database is not connected' do
+ before do
+ allow(ActiveRecord::Base).to receive(:connected?).and_return(false)
+ end
+
+ it 'records no samples' do
+ expect(subject.metrics[:size]).not_to receive(:set).with(labels, anything)
+
+ subject.sample
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/samplers/influx_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/influx_sampler_spec.rb
deleted file mode 100644
index 939c057c342..00000000000
--- a/spec/lib/gitlab/metrics/samplers/influx_sampler_spec.rb
+++ /dev/null
@@ -1,105 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe Gitlab::Metrics::Samplers::InfluxSampler do
- let(:sampler) { described_class.new(5) }
-
- describe '#start' do
- it 'runs once and gathers a sample at a given interval' do
- expect(sampler).to receive(:sleep).with(a_kind_of(Numeric)).twice
- expect(sampler).to receive(:sample).once
- expect(sampler).to receive(:running).and_return(true, false)
-
- sampler.start.join
- end
- end
-
- describe '#sample' do
- it 'samples various statistics' do
- expect(sampler).to receive(:sample_memory_usage)
- expect(sampler).to receive(:sample_file_descriptors)
- expect(sampler).to receive(:flush)
-
- sampler.sample
- end
- end
-
- describe '#flush' do
- it 'schedules the metrics using Sidekiq' do
- expect(Gitlab::Metrics).to receive(:submit_metrics)
- .with([an_instance_of(Hash)])
-
- sampler.sample_memory_usage
- sampler.flush
- end
- end
-
- describe '#sample_memory_usage' do
- it 'adds a metric containing the memory usage' do
- expect(Gitlab::Metrics::System).to receive(:memory_usage)
- .and_return(9000)
-
- expect(sampler).to receive(:add_metric)
- .with(/memory_usage/, value: 9000)
- .and_call_original
-
- sampler.sample_memory_usage
- end
- end
-
- describe '#sample_file_descriptors' do
- it 'adds a metric containing the amount of open file descriptors' do
- expect(Gitlab::Metrics::System).to receive(:file_descriptor_count)
- .and_return(4)
-
- expect(sampler).to receive(:add_metric)
- .with(/file_descriptors/, value: 4)
- .and_call_original
-
- sampler.sample_file_descriptors
- end
- end
-
- describe '#add_metric' do
- it 'prefixes the series name for a Rails process' do
- expect(Gitlab::Runtime).to receive(:sidekiq?).and_return(false)
-
- expect(Gitlab::Metrics::Metric).to receive(:new)
- .with('rails_cats', { value: 10 }, {})
- .and_call_original
-
- sampler.add_metric('cats', value: 10)
- end
-
- it 'prefixes the series name for a Sidekiq process' do
- expect(Gitlab::Runtime).to receive(:sidekiq?).and_return(true)
-
- expect(Gitlab::Metrics::Metric).to receive(:new)
- .with('sidekiq_cats', { value: 10 }, {})
- .and_call_original
-
- sampler.add_metric('cats', value: 10)
- end
- end
-
- describe '#sleep_interval' do
- it 'returns a Numeric' do
- expect(sampler.sleep_interval).to be_a_kind_of(Numeric)
- end
-
- # Testing random behaviour is very hard, so treat this test as a basic smoke
- # test instead of a very accurate behaviour/unit test.
- it 'does not return the same interval twice in a row' do
- last = nil
-
- 100.times do
- interval = sampler.sleep_interval
-
- expect(interval).not_to eq(last)
-
- last = interval
- end
- 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 8c4071a7ed1..ead650a27f0 100644
--- a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb
+++ b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb
@@ -19,24 +19,38 @@ describe Gitlab::Metrics::Samplers::RubySampler do
end
describe '#sample' do
- it 'samples various statistics' do
- expect(Gitlab::Metrics::System).to receive(:cpu_time)
- expect(Gitlab::Metrics::System).to receive(:file_descriptor_count)
- expect(Gitlab::Metrics::System).to receive(:memory_usage)
- expect(Gitlab::Metrics::System).to receive(:max_open_file_descriptors)
- expect(sampler).to receive(:sample_gc)
+ it 'adds a metric containing the process resident memory bytes' do
+ expect(Gitlab::Metrics::System).to receive(:memory_usage_rss).and_return(9000)
+
+ expect(sampler.metrics[:process_resident_memory_bytes]).to receive(:set).with({}, 9000)
sampler.sample
end
- it 'adds a metric containing the process resident memory bytes' do
- expect(Gitlab::Metrics::System).to receive(:memory_usage).and_return(9000)
+ it 'adds a metric containing the process unique and proportional memory bytes' do
+ expect(Gitlab::Metrics::System).to receive(:memory_usage_uss_pss).and_return(uss: 9000, pss: 10_000)
- expect(sampler.metrics[:process_resident_memory_bytes]).to receive(:set).with({}, 9000)
+ expect(sampler.metrics[:process_unique_memory_bytes]).to receive(:set).with({}, 9000)
+ expect(sampler.metrics[:process_proportional_memory_bytes]).to receive(:set).with({}, 10_000)
sampler.sample
end
+ context 'when USS+PSS sampling is disabled via environment' do
+ before do
+ stub_env('enable_memory_uss_pss', "0")
+ end
+
+ it 'does not sample USS or PSS' do
+ expect(Gitlab::Metrics::System).not_to receive(:memory_usage_uss_pss)
+
+ expect(sampler.metrics[:process_unique_memory_bytes]).not_to receive(:set)
+ expect(sampler.metrics[:process_proportional_memory_bytes]).not_to receive(:set)
+
+ sampler.sample
+ end
+ end
+
it 'adds a metric containing the amount of open file descriptors' do
expect(Gitlab::Metrics::System).to receive(:file_descriptor_count)
.and_return(4)
diff --git a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb
index bb95d5ab2ad..67336cf83e6 100644
--- a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb
+++ b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb
@@ -17,8 +17,6 @@ describe Gitlab::Metrics::SidekiqMiddleware do
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set)
.with(:sidekiq_queue_duration, instance_of(Float))
- expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
-
middleware.call(worker, message, :test) { nil }
end
@@ -32,8 +30,6 @@ describe Gitlab::Metrics::SidekiqMiddleware do
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set)
.with(:sidekiq_queue_duration, instance_of(Float))
- expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
-
middleware.call(worker, {}, :test) { nil }
end
@@ -46,9 +42,6 @@ describe Gitlab::Metrics::SidekiqMiddleware do
expect_any_instance_of(Gitlab::Metrics::Transaction)
.to receive(:add_event).with(:sidekiq_exception)
- expect_any_instance_of(Gitlab::Metrics::Transaction)
- .to receive(:finish)
-
expect { middleware.call(worker, message, :test) }
.to raise_error(RuntimeError)
end
diff --git a/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb b/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb
index 25c0e7b695a..857e54d3432 100644
--- a/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb
@@ -21,15 +21,9 @@ describe Gitlab::Metrics::Subscribers::ActionView do
describe '#render_template' do
it 'tracks rendering of a template' do
- values = { duration: 2.1 }
- tags = { view: 'app/views/x.html.haml' }
-
expect(transaction).to receive(:increment)
.with(:view_duration, 2.1)
- expect(transaction).to receive(:add_metric)
- .with(described_class::SERIES, values, tags)
-
subscriber.render_template(event)
end
diff --git a/spec/lib/gitlab/metrics/system_spec.rb b/spec/lib/gitlab/metrics/system_spec.rb
index a5aa80686fd..abb6a0096d6 100644
--- a/spec/lib/gitlab/metrics/system_spec.rb
+++ b/spec/lib/gitlab/metrics/system_spec.rb
@@ -3,33 +3,122 @@
require 'spec_helper'
describe Gitlab::Metrics::System do
- if File.exist?('/proc')
- describe '.memory_usage' do
- it "returns the process' memory usage in bytes" do
- expect(described_class.memory_usage).to be > 0
+ context 'when /proc files exist' do
+ # Fixtures pulled from:
+ # Linux carbon 5.3.0-7648-generic #41~1586789791~19.10~9593806-Ubuntu SMP Mon Apr 13 17:50:40 UTC x86_64 x86_64 x86_64 GNU/Linux
+ let(:proc_status) do
+ # most rows omitted for brevity
+ <<~SNIP
+ Name: less
+ VmHWM: 2468 kB
+ VmRSS: 2468 kB
+ RssAnon: 260 kB
+ SNIP
+ end
+
+ let(:proc_smaps_rollup) do
+ # full snapshot
+ <<~SNIP
+ Rss: 2564 kB
+ Pss: 503 kB
+ Pss_Anon: 312 kB
+ Pss_File: 191 kB
+ Pss_Shmem: 0 kB
+ Shared_Clean: 2100 kB
+ Shared_Dirty: 0 kB
+ Private_Clean: 152 kB
+ Private_Dirty: 312 kB
+ Referenced: 2564 kB
+ Anonymous: 312 kB
+ LazyFree: 0 kB
+ AnonHugePages: 0 kB
+ ShmemPmdMapped: 0 kB
+ Shared_Hugetlb: 0 kB
+ Private_Hugetlb: 0 kB
+ Swap: 0 kB
+ SwapPss: 0 kB
+ Locked: 0 kB
+ SNIP
+ end
+
+ let(:proc_limits) do
+ # full snapshot
+ <<~SNIP
+ Limit Soft Limit Hard Limit Units
+ Max cpu time unlimited unlimited seconds
+ Max file size unlimited unlimited bytes
+ Max data size unlimited unlimited bytes
+ Max stack size 8388608 unlimited bytes
+ Max core file size 0 unlimited bytes
+ Max resident set unlimited unlimited bytes
+ Max processes 126519 126519 processes
+ Max open files 1024 1048576 files
+ Max locked memory 67108864 67108864 bytes
+ Max address space unlimited unlimited bytes
+ Max file locks unlimited unlimited locks
+ Max pending signals 126519 126519 signals
+ Max msgqueue size 819200 819200 bytes
+ Max nice priority 0 0
+ Max realtime priority 0 0
+ Max realtime timeout unlimited unlimited us
+ SNIP
+ end
+
+ describe '.memory_usage_rss' do
+ it "returns the process' resident set size (RSS) in bytes" do
+ mock_existing_proc_file('/proc/self/status', proc_status)
+
+ expect(described_class.memory_usage_rss).to eq(2527232)
end
end
describe '.file_descriptor_count' do
it 'returns the amount of open file descriptors' do
- expect(described_class.file_descriptor_count).to be > 0
+ expect(Dir).to receive(:glob).and_return(['/some/path', '/some/other/path'])
+
+ expect(described_class.file_descriptor_count).to eq(2)
end
end
describe '.max_open_file_descriptors' do
it 'returns the max allowed open file descriptors' do
- expect(described_class.max_open_file_descriptors).to be > 0
+ mock_existing_proc_file('/proc/self/limits', proc_limits)
+
+ expect(described_class.max_open_file_descriptors).to eq(1024)
+ end
+ end
+
+ describe '.memory_usage_uss_pss' do
+ it "returns the process' unique and porportional set size (USS/PSS) in bytes" do
+ mock_existing_proc_file('/proc/self/smaps_rollup', proc_smaps_rollup)
+
+ # (Private_Clean (152 kB) + Private_Dirty (312 kB) + Private_Hugetlb (0 kB)) * 1024
+ expect(described_class.memory_usage_uss_pss).to eq(uss: 475136, pss: 515072)
end
end
- else
- describe '.memory_usage' do
- it 'returns 0.0' do
- expect(described_class.memory_usage).to eq(0.0)
+ end
+
+ context 'when /proc files do not exist' do
+ before do
+ mock_missing_proc_file
+ end
+
+ describe '.memory_usage_rss' do
+ it 'returns 0' do
+ expect(described_class.memory_usage_rss).to eq(0)
+ end
+ end
+
+ describe '.memory_usage_uss_pss' do
+ it "returns 0 for all components" do
+ expect(described_class.memory_usage_uss_pss).to eq(uss: 0, pss: 0)
end
end
describe '.file_descriptor_count' do
it 'returns 0' do
+ expect(Dir).to receive(:glob).and_return([])
+
expect(described_class.file_descriptor_count).to eq(0)
end
end
@@ -98,4 +187,12 @@ describe Gitlab::Metrics::System do
expect(described_class.thread_cpu_duration(start_time)).to be_nil
end
end
+
+ def mock_existing_proc_file(path, content)
+ allow(File).to receive(:foreach).with(path) { |_path, &block| content.each_line(&block) }
+ end
+
+ def mock_missing_proc_file
+ allow(File).to receive(:foreach).and_raise(Errno::ENOENT)
+ end
end
diff --git a/spec/lib/gitlab/metrics/transaction_spec.rb b/spec/lib/gitlab/metrics/transaction_spec.rb
index 08de2426c5a..cf46fa3e91c 100644
--- a/spec/lib/gitlab/metrics/transaction_spec.rb
+++ b/spec/lib/gitlab/metrics/transaction_spec.rb
@@ -4,7 +4,6 @@ require 'spec_helper'
describe Gitlab::Metrics::Transaction do
let(:transaction) { described_class.new }
- let(:metric) { transaction.metrics[0] }
let(:sensitive_tags) do
{
@@ -13,12 +12,6 @@ describe Gitlab::Metrics::Transaction do
}
end
- shared_examples 'tag filter' do |sane_tags|
- it 'filters potentially sensitive tags' do
- expect(metric.tags).to eq(sane_tags)
- end
- end
-
describe '#duration' do
it 'returns the duration of a transaction in seconds' do
transaction.run { }
@@ -61,25 +54,6 @@ describe Gitlab::Metrics::Transaction do
end
end
- describe '#add_metric' do
- it 'adds a metric to the transaction' do
- transaction.add_metric('foo', value: 1)
-
- expect(metric.series).to eq('rails_foo')
- expect(metric.tags).to eq({})
- expect(metric.values).to eq(value: 1)
- end
-
- context 'with sensitive tags' do
- before do
- transaction
- .add_metric('foo', { value: 1 }, **sensitive_tags.merge(sane: 'yes'))
- end
-
- it_behaves_like 'tag filter', sane: 'yes'
- end
- end
-
describe '#method_call_for' do
it 'returns a MethodCall' do
method = transaction.method_call_for('Foo#bar', :Foo, '#bar')
@@ -88,133 +62,23 @@ describe Gitlab::Metrics::Transaction do
end
end
- describe '#increment' do
- it 'increments a counter' do
- transaction.increment(:time, 1)
- transaction.increment(:time, 2)
-
- values = metric_values(time: 3)
-
- expect(transaction).to receive(:add_metric)
- .with('transactions', values, {})
-
- transaction.track_self
- end
- end
-
- describe '#set' do
- it 'sets a value' do
- transaction.set(:number, 10)
-
- values = metric_values(number: 10)
-
- expect(transaction).to receive(:add_metric)
- .with('transactions', values, {})
-
- transaction.track_self
- end
- end
-
- describe '#finish' do
- it 'tracks the transaction details and submits them to Sidekiq' do
- expect(transaction).to receive(:track_self)
- expect(transaction).to receive(:submit)
-
- transaction.finish
- end
- end
-
- describe '#track_self' do
- it 'adds a metric for the transaction itself' do
- values = metric_values
-
- expect(transaction).to receive(:add_metric)
- .with('transactions', values, {})
-
- transaction.track_self
- end
- end
-
- describe '#submit' do
- it 'submits the metrics to Sidekiq' do
- transaction.track_self
-
- expect(Gitlab::Metrics).to receive(:submit_metrics)
- .with([an_instance_of(Hash)])
-
- transaction.submit
- end
-
- it 'adds the action as a tag for every metric' do
- allow(transaction)
- .to receive(:labels)
- .and_return(controller: 'Foo', action: 'bar')
-
- transaction.track_self
-
- hash = {
- series: 'rails_transactions',
- tags: { action: 'Foo#bar' },
- values: metric_values,
- timestamp: a_kind_of(Integer)
- }
-
- expect(Gitlab::Metrics).to receive(:submit_metrics)
- .with([hash])
-
- transaction.submit
- end
-
- it 'does not add an action tag for events' do
- allow(transaction)
- .to receive(:labels)
- .and_return(controller: 'Foo', action: 'bar')
-
- transaction.add_event(:meow)
-
- hash = {
- series: 'events',
- tags: { event: :meow },
- values: { count: 1 },
- timestamp: a_kind_of(Integer)
- }
-
- expect(Gitlab::Metrics).to receive(:submit_metrics)
- .with([hash])
-
- transaction.submit
- end
- end
-
describe '#add_event' do
- it 'adds a metric' do
- transaction.add_event(:meow)
+ let(:prometheus_metric) { instance_double(Prometheus::Client::Counter, increment: nil) }
- expect(metric).to be_an_instance_of(Gitlab::Metrics::Metric)
+ before do
+ allow(described_class).to receive(:transaction_metric).and_return(prometheus_metric)
end
- it "does not prefix the metric's series name" do
- transaction.add_event(:meow)
-
- expect(metric.series).to eq(described_class::EVENT_SERIES)
- end
-
- it 'tracks a counter for every event' do
- transaction.add_event(:meow)
-
- expect(metric.values).to eq(count: 1)
- end
+ it 'adds a metric' do
+ expect(prometheus_metric).to receive(:increment)
- it 'tracks the event name' do
transaction.add_event(:meow)
-
- expect(metric.tags).to eq(event: :meow)
end
it 'allows tracking of custom tags' do
- transaction.add_event(:bau, animal: 'dog')
+ expect(prometheus_metric).to receive(:increment).with(hash_including(animal: "dog"))
- expect(metric.tags).to eq(event: :bau, animal: 'dog')
+ transaction.add_event(:bau, animal: 'dog')
end
context 'with sensitive tags' do
@@ -222,16 +86,11 @@ describe Gitlab::Metrics::Transaction do
transaction.add_event(:baubau, **sensitive_tags.merge(sane: 'yes'))
end
- it_behaves_like 'tag filter', event: :baubau, sane: 'yes'
- end
- end
-
- private
+ it 'filters tags' do
+ expect(prometheus_metric).not_to receive(:increment).with(hash_including(sensitive_tags))
- def metric_values(opts = {})
- {
- duration: 0.0,
- allocated_memory: a_kind_of(Numeric)
- }.merge(opts)
+ transaction.add_event(:baubau, **sensitive_tags.merge(sane: 'yes'))
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/metrics/web_transaction_spec.rb b/spec/lib/gitlab/metrics/web_transaction_spec.rb
index 21a762dbf25..47f1bd3bd10 100644
--- a/spec/lib/gitlab/metrics/web_transaction_spec.rb
+++ b/spec/lib/gitlab/metrics/web_transaction_spec.rb
@@ -5,6 +5,11 @@ require 'spec_helper'
describe Gitlab::Metrics::WebTransaction do
let(:env) { {} }
let(:transaction) { described_class.new(env) }
+ let(:prometheus_metric) { double("prometheus metric") }
+
+ before do
+ allow(described_class).to receive(:transaction_metric).and_return(prometheus_metric)
+ end
describe '#duration' do
it 'returns the duration of a transaction in seconds' do
@@ -40,15 +45,6 @@ describe Gitlab::Metrics::WebTransaction do
end
end
- describe '#add_metric' do
- it 'adds a metric to the transaction' do
- expect(Gitlab::Metrics::Metric).to receive(:new)
- .with('rails_foo', { number: 10 }, {})
-
- transaction.add_metric('foo', number: 10)
- end
- end
-
describe '#method_call_for' do
it 'returns a MethodCall' do
method = transaction.method_call_for('Foo#bar', :Foo, '#bar')
@@ -59,101 +55,17 @@ describe Gitlab::Metrics::WebTransaction do
describe '#increment' do
it 'increments a counter' do
- transaction.increment(:time, 1)
- transaction.increment(:time, 2)
-
- values = { duration: 0.0, time: 3, allocated_memory: a_kind_of(Numeric) }
+ expect(prometheus_metric).to receive(:increment).with({}, 1)
- expect(transaction).to receive(:add_metric)
- .with('transactions', values, {})
-
- transaction.track_self
+ transaction.increment(:time, 1)
end
end
describe '#set' do
it 'sets a value' do
- transaction.set(:number, 10)
-
- values = {
- duration: 0.0,
- number: 10,
- allocated_memory: a_kind_of(Numeric)
- }
-
- expect(transaction).to receive(:add_metric)
- .with('transactions', values, {})
-
- transaction.track_self
- end
- end
-
- describe '#finish' do
- it 'tracks the transaction details and submits them to Sidekiq' do
- expect(transaction).to receive(:track_self)
- expect(transaction).to receive(:submit)
-
- transaction.finish
- end
- end
-
- describe '#track_self' do
- it 'adds a metric for the transaction itself' do
- values = {
- duration: transaction.duration,
- allocated_memory: a_kind_of(Numeric)
- }
-
- expect(transaction).to receive(:add_metric)
- .with('transactions', values, {})
-
- transaction.track_self
- end
- end
-
- describe '#submit' do
- it 'submits the metrics to Sidekiq' do
- transaction.track_self
-
- expect(Gitlab::Metrics).to receive(:submit_metrics)
- .with([an_instance_of(Hash)])
-
- transaction.submit
- end
+ expect(prometheus_metric).to receive(:set).with({}, 10)
- it 'adds the action as a tag for every metric' do
- allow(transaction).to receive(:labels).and_return(controller: 'Foo', action: 'bar')
- transaction.track_self
-
- hash = {
- series: 'rails_transactions',
- tags: { action: 'Foo#bar' },
- values: { duration: 0.0, allocated_memory: a_kind_of(Numeric) },
- timestamp: a_kind_of(Integer)
- }
-
- expect(Gitlab::Metrics).to receive(:submit_metrics)
- .with([hash])
-
- transaction.submit
- end
-
- it 'does not add an action tag for events' do
- allow(transaction).to receive(:labels).and_return(controller: 'Foo', action: 'bar')
-
- transaction.add_event(:meow)
-
- hash = {
- series: 'events',
- tags: { event: :meow },
- values: { count: 1 },
- timestamp: a_kind_of(Integer)
- }
-
- expect(Gitlab::Metrics).to receive(:submit_metrics)
- .with([hash])
-
- transaction.submit
+ transaction.set(:number, 10)
end
end
@@ -167,7 +79,6 @@ describe Gitlab::Metrics::WebTransaction do
end
it 'provides labels with the method and path of the route in the grape endpoint' do
expect(transaction.labels).to eq({ controller: 'Grape', action: 'GET /projects/:id/archive' })
- expect(transaction.action).to eq('Grape#GET /projects/:id/archive')
end
it 'does not provide labels if route infos are missing' do
@@ -177,7 +88,6 @@ describe Gitlab::Metrics::WebTransaction do
env['api.endpoint'] = endpoint
expect(transaction.labels).to eq({})
- expect(transaction.action).to be_nil
end
end
@@ -193,7 +103,6 @@ describe Gitlab::Metrics::WebTransaction do
it 'tags a transaction with the name and action of a controller' do
expect(transaction.labels).to eq({ controller: 'TestController', action: 'show' })
- expect(transaction.action).to eq('TestController#show')
end
context 'when the request content type is not :html' do
@@ -201,7 +110,6 @@ describe Gitlab::Metrics::WebTransaction do
it 'appends the mime type to the transaction action' do
expect(transaction.labels).to eq({ controller: 'TestController', action: 'show.json' })
- expect(transaction.action).to eq('TestController#show.json')
end
end
@@ -210,54 +118,26 @@ describe Gitlab::Metrics::WebTransaction do
it 'does not append the MIME type to the transaction action' do
expect(transaction.labels).to eq({ controller: 'TestController', action: 'show' })
- expect(transaction.action).to eq('TestController#show')
end
end
end
it 'returns no labels when no route information is present in env' do
expect(transaction.labels).to eq({})
- expect(transaction.action).to eq(nil)
end
end
describe '#add_event' do
it 'adds a metric' do
- transaction.add_event(:meow)
+ expect(prometheus_metric).to receive(:increment)
- expect(transaction.metrics[0]).to be_an_instance_of(Gitlab::Metrics::Metric)
- end
-
- it "does not prefix the metric's series name" do
transaction.add_event(:meow)
-
- metric = transaction.metrics[0]
-
- expect(metric.series).to eq(described_class::EVENT_SERIES)
- end
-
- it 'tracks a counter for every event' do
- transaction.add_event(:meow)
-
- metric = transaction.metrics[0]
-
- expect(metric.values).to eq(count: 1)
- end
-
- it 'tracks the event name' do
- transaction.add_event(:meow)
-
- metric = transaction.metrics[0]
-
- expect(metric.tags).to eq(event: :meow)
end
it 'allows tracking of custom tags' do
- transaction.add_event(:bau, animal: 'dog')
-
- metric = transaction.metrics[0]
+ expect(prometheus_metric).to receive(:increment).with(animal: "dog")
- expect(metric.tags).to eq(event: :bau, animal: 'dog')
+ transaction.add_event(:bau, animal: 'dog')
end
end
end
diff --git a/spec/lib/gitlab/metrics_spec.rb b/spec/lib/gitlab/metrics_spec.rb
index f0ba12c1cd0..2ebe1958487 100644
--- a/spec/lib/gitlab/metrics_spec.rb
+++ b/spec/lib/gitlab/metrics_spec.rb
@@ -53,60 +53,6 @@ describe Gitlab::Metrics do
end
end
- describe '.influx_metrics_enabled?' do
- it 'returns a boolean' do
- expect(described_class.influx_metrics_enabled?).to be_in([true, false])
- end
- end
-
- describe '.submit_metrics' do
- it 'prepares and writes the metrics to InfluxDB' do
- connection = double(:connection)
- pool = double(:pool)
-
- expect(pool).to receive(:with).and_yield(connection)
- expect(connection).to receive(:write_points).with(an_instance_of(Array))
- expect(described_class).to receive(:pool).and_return(pool)
-
- described_class.submit_metrics([{ 'series' => 'kittens', 'tags' => {} }])
- end
- end
-
- describe '.prepare_metrics' do
- it 'returns a Hash with the keys as Symbols' do
- metrics = described_class
- .prepare_metrics([{ 'values' => {}, 'tags' => {} }])
-
- expect(metrics).to eq([{ values: {}, tags: {} }])
- end
-
- it 'escapes tag values' do
- metrics = described_class.prepare_metrics([
- { 'values' => {}, 'tags' => { 'foo' => 'bar=' } }
- ])
-
- expect(metrics).to eq([{ values: {}, tags: { 'foo' => 'bar\\=' } }])
- end
-
- it 'drops empty tags' do
- metrics = described_class.prepare_metrics([
- { 'values' => {}, 'tags' => { 'cats' => '', 'dogs' => nil } }
- ])
-
- expect(metrics).to eq([{ values: {}, tags: {} }])
- end
- end
-
- describe '.escape_value' do
- it 'escapes an equals sign' do
- expect(described_class.escape_value('foo=')).to eq('foo\\=')
- end
-
- it 'casts values to Strings' do
- expect(described_class.escape_value(10)).to eq('10')
- end
- end
-
describe '.measure' do
context 'without a transaction' do
it 'returns the return value of the block' do
@@ -145,30 +91,6 @@ describe Gitlab::Metrics do
end
end
- describe '.action=' do
- context 'without a transaction' do
- it 'does nothing' do
- expect_any_instance_of(Gitlab::Metrics::Transaction)
- .not_to receive(:action=)
-
- described_class.action = 'foo'
- end
- end
-
- context 'with a transaction' do
- it 'sets the action of a transaction' do
- trans = Gitlab::Metrics::WebTransaction.new({})
-
- expect(described_class).to receive(:current_transaction)
- .and_return(trans)
-
- expect(trans).to receive(:action=).with('foo')
-
- described_class.action = 'foo'
- end
- end
- end
-
describe '#series_prefix' do
it 'returns a String' do
expect(described_class.series_prefix).to be_an_instance_of(String)
diff --git a/spec/lib/gitlab/middleware/multipart_spec.rb b/spec/lib/gitlab/middleware/multipart_spec.rb
index c99281ee12c..705164d5445 100644
--- a/spec/lib/gitlab/middleware/multipart_spec.rb
+++ b/spec/lib/gitlab/middleware/multipart_spec.rb
@@ -195,6 +195,17 @@ describe Gitlab::Middleware::Multipart do
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'
diff --git a/spec/lib/gitlab/omniauth_initializer_spec.rb b/spec/lib/gitlab/omniauth_initializer_spec.rb
index 99684bb2ab2..4afe4545891 100644
--- a/spec/lib/gitlab/omniauth_initializer_spec.rb
+++ b/spec/lib/gitlab/omniauth_initializer_spec.rb
@@ -86,6 +86,22 @@ describe Gitlab::OmniauthInitializer do
subject.execute([cas3_config])
end
+ it 'configures defaults for google_oauth2' do
+ google_config = {
+ 'name' => 'google_oauth2',
+ "args" => { "access_type" => "offline", "approval_prompt" => '' }
+ }
+
+ expect(devise_config).to receive(:omniauth).with(
+ :google_oauth2,
+ access_type: "offline",
+ approval_prompt: "",
+ client_options: { connection_opts: { request: { timeout: Gitlab::OmniauthInitializer::OAUTH2_TIMEOUT_SECONDS } } }
+ )
+
+ subject.execute([google_config])
+ end
+
it 'converts client_auth_method to a Symbol for openid_connect' do
openid_connect_config = {
'name' => 'openid_connect',
diff --git a/spec/lib/gitlab/pagination/keyset_spec.rb b/spec/lib/gitlab/pagination/keyset_spec.rb
index bde280c5fca..0ac40080872 100644
--- a/spec/lib/gitlab/pagination/keyset_spec.rb
+++ b/spec/lib/gitlab/pagination/keyset_spec.rb
@@ -3,6 +3,18 @@
require 'spec_helper'
describe Gitlab::Pagination::Keyset do
+ describe '.available_for_type?' do
+ subject { described_class }
+
+ it 'returns true for Project' do
+ expect(subject.available_for_type?(Project.all)).to be_truthy
+ end
+
+ it 'return false for other types of relations' do
+ expect(subject.available_for_type?(User.all)).to be_falsey
+ end
+ end
+
describe '.available?' do
subject { described_class }
diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb
index 8dabe5a756b..50b045c6aad 100644
--- a/spec/lib/gitlab/path_regex_spec.rb
+++ b/spec/lib/gitlab/path_regex_spec.rb
@@ -170,6 +170,11 @@ describe Gitlab::PathRegex do
expect(described_class::TOP_LEVEL_ROUTES)
.to contain_exactly(*top_level_words), failure_block
end
+
+ # We ban new items in this list, see https://gitlab.com/gitlab-org/gitlab/-/issues/215362
+ it 'does not allow expansion' do
+ expect(described_class::TOP_LEVEL_ROUTES.size).to eq(41)
+ end
end
describe 'GROUP_ROUTES' do
@@ -184,6 +189,11 @@ describe Gitlab::PathRegex do
expect(described_class::GROUP_ROUTES)
.to contain_exactly(*paths_after_group_id), failure_block
end
+
+ # We ban new items in this list, see https://gitlab.com/gitlab-org/gitlab/-/issues/215362
+ it 'does not allow expansion' do
+ expect(described_class::GROUP_ROUTES.size).to eq(1)
+ end
end
describe 'PROJECT_WILDCARD_ROUTES' do
@@ -195,6 +205,11 @@ describe Gitlab::PathRegex do
end
end
end
+
+ # We ban new items in this list, see https://gitlab.com/gitlab-org/gitlab/-/issues/215362
+ it 'does not allow expansion' do
+ expect(described_class::PROJECT_WILDCARD_ROUTES.size).to eq(21)
+ end
end
describe '.root_namespace_route_regex' do
diff --git a/spec/lib/gitlab/performance_bar_spec.rb b/spec/lib/gitlab/performance_bar_spec.rb
index 816db49d94a..7b79cc82816 100644
--- a/spec/lib/gitlab/performance_bar_spec.rb
+++ b/spec/lib/gitlab/performance_bar_spec.rb
@@ -3,42 +3,7 @@
require 'spec_helper'
describe Gitlab::PerformanceBar do
- shared_examples 'allowed user IDs are cached' do
- before do
- # Warm the caches
- described_class.enabled_for_user?(user)
- end
-
- it 'caches the allowed user IDs in cache', :use_clean_rails_memory_store_caching do
- expect do
- expect(described_class.l1_cache_backend).to receive(:fetch).and_call_original
- expect(described_class.l2_cache_backend).not_to receive(:fetch)
- expect(described_class.enabled_for_user?(user)).to be_truthy
- end.not_to exceed_query_limit(0)
- end
-
- it 'caches the allowed user IDs in L1 cache for 1 minute', :use_clean_rails_memory_store_caching do
- Timecop.travel 2.minutes do
- expect do
- expect(described_class.l1_cache_backend).to receive(:fetch).and_call_original
- expect(described_class.l2_cache_backend).to receive(:fetch).and_call_original
- expect(described_class.enabled_for_user?(user)).to be_truthy
- end.not_to exceed_query_limit(0)
- end
- end
-
- it 'caches the allowed user IDs in L2 cache for 5 minutes', :use_clean_rails_memory_store_caching do
- Timecop.travel 6.minutes do
- expect do
- expect(described_class.l1_cache_backend).to receive(:fetch).and_call_original
- expect(described_class.l2_cache_backend).to receive(:fetch).and_call_original
- expect(described_class.enabled_for_user?(user)).to be_truthy
- end.not_to exceed_query_limit(2)
- end
- end
- end
-
- it { expect(described_class.l1_cache_backend).to eq(Gitlab::ThreadMemoryCache.cache_backend) }
+ it { expect(described_class.l1_cache_backend).to eq(Gitlab::ProcessMemoryCache.cache_backend) }
it { expect(described_class.l2_cache_backend).to eq(Rails.cache) }
describe '.enabled_for_user?' do
@@ -82,7 +47,16 @@ describe Gitlab::PerformanceBar do
expect(described_class.enabled_for_user?(user)).to be_falsy
end
- it_behaves_like 'allowed user IDs are cached'
+ context 'caching of allowed user IDs' do
+ subject { described_class.enabled_for_user?(user) }
+
+ before do
+ # Warm the caches
+ described_class.enabled_for_user?(user)
+ end
+
+ it_behaves_like 'allowed user IDs are cached'
+ end
end
context 'when user is a member of the allowed group' do
@@ -94,7 +68,16 @@ describe Gitlab::PerformanceBar do
expect(described_class.enabled_for_user?(user)).to be_truthy
end
- it_behaves_like 'allowed user IDs are cached'
+ context 'caching of allowed user IDs' do
+ subject { described_class.enabled_for_user?(user) }
+
+ before do
+ # Warm the caches
+ described_class.enabled_for_user?(user)
+ end
+
+ it_behaves_like 'allowed user IDs are cached'
+ end
end
end
diff --git a/spec/lib/gitlab/phabricator_import/conduit/response_spec.rb b/spec/lib/gitlab/phabricator_import/conduit/response_spec.rb
index a8596968f14..1ffb811cbc1 100644
--- a/spec/lib/gitlab/phabricator_import/conduit/response_spec.rb
+++ b/spec/lib/gitlab/phabricator_import/conduit/response_spec.rb
@@ -2,8 +2,8 @@
require 'spec_helper'
describe Gitlab::PhabricatorImport::Conduit::Response do
- let(:response) { described_class.new(JSON.parse(fixture_file('phabricator_responses/maniphest.search.json')))}
- let(:error_response) { described_class.new(JSON.parse(fixture_file('phabricator_responses/auth_failed.json'))) }
+ let(:response) { described_class.new(Gitlab::Json.parse(fixture_file('phabricator_responses/maniphest.search.json')))}
+ let(:error_response) { described_class.new(Gitlab::Json.parse(fixture_file('phabricator_responses/auth_failed.json'))) }
describe '.parse!' do
it 'raises a ResponseError if the http response was not successfull' do
diff --git a/spec/lib/gitlab/phabricator_import/conduit/tasks_response_spec.rb b/spec/lib/gitlab/phabricator_import/conduit/tasks_response_spec.rb
index 4b4c2a6276e..2cc12ee0165 100644
--- a/spec/lib/gitlab/phabricator_import/conduit/tasks_response_spec.rb
+++ b/spec/lib/gitlab/phabricator_import/conduit/tasks_response_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
describe Gitlab::PhabricatorImport::Conduit::TasksResponse do
let(:conduit_response) do
Gitlab::PhabricatorImport::Conduit::Response
- .new(JSON.parse(fixture_file('phabricator_responses/maniphest.search.json')))
+ .new(Gitlab::Json.parse(fixture_file('phabricator_responses/maniphest.search.json')))
end
subject(:response) { described_class.new(conduit_response) }
diff --git a/spec/lib/gitlab/phabricator_import/conduit/users_response_spec.rb b/spec/lib/gitlab/phabricator_import/conduit/users_response_spec.rb
index 00778ad90fd..999a986b73c 100644
--- a/spec/lib/gitlab/phabricator_import/conduit/users_response_spec.rb
+++ b/spec/lib/gitlab/phabricator_import/conduit/users_response_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
describe Gitlab::PhabricatorImport::Conduit::UsersResponse do
let(:conduit_response) do
Gitlab::PhabricatorImport::Conduit::Response
- .new(JSON.parse(fixture_file('phabricator_responses/user.search.json')))
+ .new(Gitlab::Json.parse(fixture_file('phabricator_responses/user.search.json')))
end
subject(:response) { described_class.new(conduit_response) }
diff --git a/spec/lib/gitlab/phabricator_import/issues/importer_spec.rb b/spec/lib/gitlab/phabricator_import/issues/importer_spec.rb
index 667321409da..02dafd4bb3b 100644
--- a/spec/lib/gitlab/phabricator_import/issues/importer_spec.rb
+++ b/spec/lib/gitlab/phabricator_import/issues/importer_spec.rb
@@ -7,7 +7,7 @@ describe Gitlab::PhabricatorImport::Issues::Importer do
let(:response) do
Gitlab::PhabricatorImport::Conduit::TasksResponse.new(
Gitlab::PhabricatorImport::Conduit::Response
- .new(JSON.parse(fixture_file('phabricator_responses/maniphest.search.json')))
+ .new(Gitlab::Json.parse(fixture_file('phabricator_responses/maniphest.search.json')))
)
end
diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb
index d206d31eb96..64f80b5d736 100644
--- a/spec/lib/gitlab/project_search_results_spec.rb
+++ b/spec/lib/gitlab/project_search_results_spec.rb
@@ -45,22 +45,36 @@ describe Gitlab::ProjectSearchResults do
expect(results.formatted_count(scope)).to eq(expected)
end
end
+
+ context 'blobs' do
+ it "limits the search to #{described_class::COUNT_LIMIT} items" do
+ expect(results).to receive(:blobs).with(limit: described_class::COUNT_LIMIT).and_call_original
+ expect(results.formatted_count('blobs')).to eq('0')
+ end
+ end
+
+ context 'wiki_blobs' do
+ it "limits the search to #{described_class::COUNT_LIMIT} items" do
+ expect(results).to receive(:wiki_blobs).with(limit: described_class::COUNT_LIMIT).and_call_original
+ expect(results.formatted_count('wiki_blobs')).to eq('0')
+ end
+ end
end
- shared_examples 'general blob search' do |entity_type, blob_kind|
+ shared_examples 'general blob search' do |entity_type, blob_type|
let(:query) { 'files' }
subject(:results) { described_class.new(user, project, query).objects(blob_type) }
context "when #{entity_type} is disabled" do
let(:project) { disabled_project }
- it "hides #{blob_kind} from members" do
+ it "hides #{blob_type} from members" do
project.add_reporter(user)
is_expected.to be_empty
end
- it "hides #{blob_kind} from non-members" do
+ it "hides #{blob_type} from non-members" do
is_expected.to be_empty
end
end
@@ -68,13 +82,13 @@ describe Gitlab::ProjectSearchResults do
context "when #{entity_type} is internal" do
let(:project) { private_project }
- it "finds #{blob_kind} for members" do
+ it "finds #{blob_type} for members" do
project.add_reporter(user)
is_expected.not_to be_empty
end
- it "hides #{blob_kind} from non-members" do
+ it "hides #{blob_type} from non-members" do
is_expected.to be_empty
end
end
@@ -96,7 +110,7 @@ describe Gitlab::ProjectSearchResults do
end
end
- shared_examples 'blob search repository ref' do |entity_type|
+ shared_examples 'blob search repository ref' do |entity_type, blob_type|
let(:query) { 'files' }
let(:file_finder) { double }
let(:project_branch) { 'project_branch' }
@@ -139,9 +153,41 @@ describe Gitlab::ProjectSearchResults do
end
end
+ shared_examples 'blob search pagination' do |blob_type|
+ 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
+ allow(file_finder).to receive(:find).and_return([])
+ expect(Gitlab::FileFinder).to receive(:new).with(project, repository_ref).and_return(file_finder)
+ end
+
+ it 'limits search results based on the first page' do
+ expect(file_finder).to receive(:find).with(query, content_match_cutoff: count_limit)
+ results.objects(blob_type, page: 1, per_page: per_page)
+ end
+
+ it 'limits search results based on the second page' do
+ expect(file_finder).to receive(:find).with(query, content_match_cutoff: count_limit + per_page)
+ results.objects(blob_type, page: 2, per_page: per_page)
+ end
+
+ it 'limits search results based on the third page' do
+ expect(file_finder).to receive(:find).with(query, content_match_cutoff: count_limit + per_page * 2)
+ results.objects(blob_type, page: 3, per_page: per_page)
+ end
+
+ it 'uses the per_page value when passed' do
+ expect(file_finder).to receive(:find).with(query, content_match_cutoff: count_limit + 10 * 2)
+ results.objects(blob_type, page: 3, per_page: 10)
+ end
+ end
+
describe 'blob search' do
let(:project) { create(:project, :public, :repository) }
- let(:blob_type) { 'blobs' }
it_behaves_like 'general blob search', 'repository', 'blobs' do
let(:disabled_project) { create(:project, :public, :repository, :repository_disabled) }
@@ -150,37 +196,11 @@ describe Gitlab::ProjectSearchResults do
let(:expected_file_by_content) { 'CHANGELOG' }
end
- it_behaves_like 'blob search repository ref', 'project' do
+ it_behaves_like 'blob search repository ref', 'project', 'blobs' do
let(:entity) { project }
end
- context 'pagination' 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, per_page: per_page) }
- let(:repository_ref) { 'master' }
-
- before do
- allow(file_finder).to receive(:find).and_return([])
- expect(Gitlab::FileFinder).to receive(:new).with(project, repository_ref).and_return(file_finder)
- end
-
- it 'limits search results based on the first page' do
- expect(file_finder).to receive(:find).with(query, content_match_cutoff: count_limit)
- results.objects(blob_type, 1)
- end
-
- it 'limits search results based on the second page' do
- expect(file_finder).to receive(:find).with(query, content_match_cutoff: count_limit + per_page)
- results.objects(blob_type, 2)
- end
-
- it 'limits search results based on the third page' do
- expect(file_finder).to receive(:find).with(query, content_match_cutoff: count_limit + per_page * 2)
- results.objects(blob_type, 3)
- end
- end
+ it_behaves_like 'blob search pagination', 'blobs'
end
describe 'wiki search' do
@@ -192,7 +212,7 @@ describe Gitlab::ProjectSearchResults do
wiki.create_page('CHANGELOG', 'Files example')
end
- it_behaves_like 'general blob search', 'wiki', 'wiki blobs' do
+ it_behaves_like 'general blob search', 'wiki', 'wiki_blobs' do
let(:blob_type) { 'wiki_blobs' }
let(:disabled_project) { create(:project, :public, :wiki_repo, :wiki_disabled) }
let(:private_project) { create(:project, :public, :wiki_repo, :wiki_private) }
@@ -200,10 +220,27 @@ describe Gitlab::ProjectSearchResults do
let(:expected_file_by_content) { 'CHANGELOG.md' }
end
- it_behaves_like 'blob search repository ref', 'wiki' do
- let(:blob_type) { 'wiki_blobs' }
+ it_behaves_like 'blob search repository ref', 'wiki', 'wiki_blobs' do
let(:entity) { project.wiki }
end
+
+ it_behaves_like 'blob search pagination', 'wiki_blobs'
+
+ context 'return type' do
+ let(:blobs) { [Gitlab::Search::FoundBlob.new(project: project)] }
+ let(:results) { described_class.new(user, project, "Files", 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
diff --git a/spec/lib/gitlab/prometheus_client_spec.rb b/spec/lib/gitlab/prometheus_client_spec.rb
index e869a384b29..4ff53b50a50 100644
--- a/spec/lib/gitlab/prometheus_client_spec.rb
+++ b/spec/lib/gitlab/prometheus_client_spec.rb
@@ -313,7 +313,7 @@ describe Gitlab::PrometheusClient do
req_stub = stub_prometheus_request(query_url, body: prometheus_value_body('vector'))
response = subject.proxy('query', { query: prometheus_query })
- json_response = JSON.parse(response.body)
+ json_response = Gitlab::Json.parse(response.body)
expect(response.code).to eq(200)
expect(json_response).to eq({
@@ -332,7 +332,7 @@ describe Gitlab::PrometheusClient do
req_stub = stub_prometheus_request(query_url, status: 400, body: { error: 'error' })
response = subject.proxy('query', { query: prometheus_query })
- json_response = JSON.parse(response.body)
+ json_response = Gitlab::Json.parse(response.body)
expect(req_stub).to have_been_requested
expect(response.code).to eq(400)
diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb
index 5a2cf2eda8b..9e596400904 100644
--- a/spec/lib/gitlab/regex_spec.rb
+++ b/spec/lib/gitlab/regex_spec.rb
@@ -130,4 +130,37 @@ describe Gitlab::Regex do
it { is_expected.not_to match('aa-1234-cc') }
it { is_expected.not_to match('9/9/2018') }
end
+
+ describe '.kubernetes_namespace_regex' do
+ subject { described_class.kubernetes_namespace_regex }
+
+ it { is_expected.to match('foo') }
+ it { is_expected.to match('foo-bar') }
+ it { is_expected.to match('1foo-bar') }
+ it { is_expected.to match('foo-bar2') }
+ it { is_expected.to match('foo-1bar') }
+ it { is_expected.not_to match('foo.bar') }
+ it { is_expected.not_to match('Foo') }
+ it { is_expected.not_to match('FoO') }
+ it { is_expected.not_to match('FoO-') }
+ it { is_expected.not_to match('-foo-') }
+ it { is_expected.not_to match('foo/bar') }
+ end
+
+ describe '.kubernetes_dns_subdomain_regex' do
+ subject { described_class.kubernetes_dns_subdomain_regex }
+
+ it { is_expected.to match('foo') }
+ it { is_expected.to match('foo-bar') }
+ it { is_expected.to match('foo.bar') }
+ it { is_expected.to match('foo1.bar') }
+ it { is_expected.to match('foo1.2bar') }
+ it { is_expected.to match('foo.bar1') }
+ it { is_expected.to match('1foo.bar1') }
+ it { is_expected.not_to match('Foo') }
+ it { is_expected.not_to match('FoO') }
+ it { is_expected.not_to match('FoO-') }
+ it { is_expected.not_to match('-foo-') }
+ it { is_expected.not_to match('foo/bar') }
+ end
end
diff --git a/spec/lib/gitlab/repository_url_builder_spec.rb b/spec/lib/gitlab/repository_url_builder_spec.rb
index 3d8870ecb53..a5797146cc5 100644
--- a/spec/lib/gitlab/repository_url_builder_spec.rb
+++ b/spec/lib/gitlab/repository_url_builder_spec.rb
@@ -9,7 +9,7 @@ describe Gitlab::RepositoryUrlBuilder do
where(:factory, :path_generator) do
:project | ->(project) { project.full_path }
:project_snippet | ->(snippet) { "#{snippet.project.full_path}/snippets/#{snippet.id}" }
- :project_wiki | ->(wiki) { "#{wiki.project.full_path}.wiki" }
+ :project_wiki | ->(wiki) { "#{wiki.container.full_path}.wiki" }
:personal_snippet | ->(snippet) { "snippets/#{snippet.id}" }
end
diff --git a/spec/lib/gitlab/request_context_spec.rb b/spec/lib/gitlab/request_context_spec.rb
index 7e2e05c9f1b..d7af0765d53 100644
--- a/spec/lib/gitlab/request_context_spec.rb
+++ b/spec/lib/gitlab/request_context_spec.rb
@@ -5,6 +5,10 @@ require 'spec_helper'
describe Gitlab::RequestContext, :request_store do
subject { described_class.instance }
+ before do
+ allow(subject).to receive(:enabled?).and_return(true)
+ end
+
it { is_expected.to have_attributes(client_ip: nil, start_thread_cpu_time: nil, request_start_time: nil) }
describe '#request_deadline' do
diff --git a/spec/lib/gitlab/runtime_spec.rb b/spec/lib/gitlab/runtime_spec.rb
index 34a775fc206..8f920bb2e01 100644
--- a/spec/lib/gitlab/runtime_spec.rb
+++ b/spec/lib/gitlab/runtime_spec.rb
@@ -105,4 +105,17 @@ describe Gitlab::Runtime do
it_behaves_like "valid runtime", :rails_runner, 1
end
+
+ context "action_cable" do
+ before do
+ stub_const('ACTION_CABLE_SERVER', true)
+ stub_const('::Puma', Module.new)
+
+ allow(Gitlab::Application).to receive_message_chain(:config, :action_cable, :worker_pool_size).and_return(8)
+ end
+
+ it "reports its maximum concurrency based on ActionCable's worker pool size" do
+ expect(subject.max_threads).to eq(9)
+ end
+ end
end
diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb
index 86dde15cc8a..ab14602a468 100644
--- a/spec/lib/gitlab/search_results_spec.rb
+++ b/spec/lib/gitlab/search_results_spec.rb
@@ -28,7 +28,15 @@ describe Gitlab::SearchResults do
end
it 'returns with counts collection when requested' do
- expect(results.objects('projects', 1, false)).not_to be_kind_of(Kaminari::PaginatableWithoutCount)
+ expect(results.objects('projects', page: 1, per_page: 1, without_count: false)).not_to be_kind_of(Kaminari::PaginatableWithoutCount)
+ end
+
+ it 'uses page and per_page to paginate results' do
+ project2 = create(:project, name: 'foo')
+
+ expect(results.objects('projects', page: 1, per_page: 1).to_a).to eq([project])
+ expect(results.objects('projects', page: 2, per_page: 1).to_a).to eq([project2])
+ expect(results.objects('projects', page: 1, per_page: 2).count).to eq(2)
end
end
diff --git a/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb b/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb
index 0aaff12f278..80e8da58f23 100644
--- a/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb
+++ b/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb
@@ -54,14 +54,6 @@ describe Gitlab::SidekiqConfig::CliMethods do
end
end
- context 'when the file contains an array of strings' do
- before do
- stub_contents(['queue_a'], ['queue_b'])
- end
-
- include_examples 'valid file contents'
- end
-
context 'when the file contains an array of hashes' do
before do
stub_contents([{ name: 'queue_a' }], [{ name: 'queue_b' }])
diff --git a/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb b/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb
index 2f5343627d8..283140d7fdf 100644
--- a/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb
+++ b/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb
@@ -9,7 +9,7 @@ describe Gitlab::SidekiqLogging::JSONFormatter do
let(:timestamp_iso8601) { now.iso8601(3) }
describe 'with a Hash' do
- subject { JSON.parse(described_class.new.call('INFO', now, 'my program', hash_input)) }
+ subject { Gitlab::Json.parse(described_class.new.call('INFO', now, 'my program', hash_input)) }
let(:hash_input) do
{
@@ -34,7 +34,8 @@ describe Gitlab::SidekiqLogging::JSONFormatter do
'started_at' => timestamp_iso8601,
'retried_at' => timestamp_iso8601,
'failed_at' => timestamp_iso8601,
- 'completed_at' => timestamp_iso8601
+ 'completed_at' => timestamp_iso8601,
+ 'retry' => 0
}
)
@@ -57,13 +58,33 @@ describe Gitlab::SidekiqLogging::JSONFormatter do
expect(subject['args']).to eq(["1", "test", "2", %({"test"=>1})])
end
+
+ context 'when the job has a non-integer value for retry' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:retry_in_job, :retry_in_logs) do
+ 3 | 3
+ true | 25
+ false | 0
+ nil | 0
+ 'string' | -1
+ end
+
+ with_them do
+ it 'logs as the correct integer' do
+ hash_input['retry'] = retry_in_job
+
+ expect(subject['retry']).to eq(retry_in_logs)
+ end
+ end
+ end
end
describe 'with a String' do
it 'accepts strings with no changes' do
result = subject.call('DEBUG', now, 'my string', message)
- data = JSON.parse(result)
+ data = Gitlab::Json.parse(result)
expected_output = {
severity: 'DEBUG',
time: timestamp_iso8601,
diff --git a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
index f4b939c3013..a4bbb51baae 100644
--- a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
+++ b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
@@ -44,7 +44,7 @@ describe Gitlab::SidekiqLogging::StructuredLogger do
'job_status' => 'done',
'duration_s' => 0.0,
'completed_at' => timestamp.to_f,
- 'cpu_s' => 1.11,
+ 'cpu_s' => 1.111112,
'db_duration_s' => 0.0
)
end
@@ -218,13 +218,34 @@ describe Gitlab::SidekiqLogging::StructuredLogger do
subject.call(job, 'test_queue') { }
end
end
+
+ context 'when there is extra metadata set for the done log' do
+ let(:expected_start_payload) { start_payload.except('args') }
+
+ let(:expected_end_payload) do
+ end_payload.except('args').merge("#{ApplicationWorker::LOGGING_EXTRA_KEY}.key1" => 15, "#{ApplicationWorker::LOGGING_EXTRA_KEY}.key2" => 16)
+ end
+
+ it 'logs it in the done log' do
+ Timecop.freeze(timestamp) do
+ expect(logger).to receive(:info).with(expected_start_payload).ordered
+ expect(logger).to receive(:info).with(expected_end_payload).ordered
+
+ subject.call(job, 'test_queue') do
+ job["#{ApplicationWorker::LOGGING_EXTRA_KEY}.key1"] = 15
+ job["#{ApplicationWorker::LOGGING_EXTRA_KEY}.key2"] = 16
+ job['key that will be ignored because it does not start with extra.'] = 17
+ end
+ end
+ end
+ end
end
describe '#add_time_keys!' do
let(:time) { { duration: 0.1231234, cputime: 1.2342345 } }
let(:payload) { { 'class' => 'my-class', 'message' => 'my-message', 'job_status' => 'my-job-status' } }
let(:current_utc_time) { Time.now.utc }
- let(:payload_with_time_keys) { { 'class' => 'my-class', 'message' => 'my-message', 'job_status' => 'my-job-status', 'duration_s' => 0.12, 'cpu_s' => 1.23, 'completed_at' => current_utc_time.to_f } }
+ let(:payload_with_time_keys) { { 'class' => 'my-class', 'message' => 'my-message', 'job_status' => 'my-job-status', 'duration_s' => 0.123123, 'cpu_s' => 1.234235, 'completed_at' => current_utc_time.to_f } }
subject { described_class.new }
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb
index 6e8a8c03aad..929df0a7ffb 100644
--- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb
@@ -113,22 +113,27 @@ describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gitlab_r
end
describe 'droppable?' do
- where(:idempotent, :duplicate) do
- # [true, false].repeated_permutation(2)
- [[true, true],
- [true, false],
- [false, true],
- [false, false]]
+ where(:idempotent, :duplicate, :prevent_deduplication) do
+ # [true, false].repeated_permutation(3)
+ [[true, true, true],
+ [true, true, false],
+ [true, false, true],
+ [true, false, false],
+ [false, true, true],
+ [false, true, false],
+ [false, false, true],
+ [false, false, false]]
end
with_them do
before do
allow(AuthorizedProjectsWorker).to receive(:idempotent?).and_return(idempotent)
allow(duplicate_job).to receive(:duplicate?).and_return(duplicate)
+ stub_feature_flags("disable_#{queue}_deduplication" => prevent_deduplication)
end
it 'is droppable when all conditions are met' do
- if idempotent && duplicate
+ if idempotent && duplicate && !prevent_deduplication
expect(duplicate_job).to be_droppable
else
expect(duplicate_job).not_to be_droppable
diff --git a/spec/lib/gitlab/sidekiq_middleware/extra_done_log_metadata_spec.rb b/spec/lib/gitlab/sidekiq_middleware/extra_done_log_metadata_spec.rb
new file mode 100644
index 00000000000..98847885e62
--- /dev/null
+++ b/spec/lib/gitlab/sidekiq_middleware/extra_done_log_metadata_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::SidekiqMiddleware::ExtraDoneLogMetadata do
+ # Cannot use Class.new for this as ApplicationWorker will need the class to
+ # have a name during `included do`.
+ let(:worker) { AdminEmailWorker.new }
+
+ let(:worker_without_application_worker) do
+ Class.new do
+ end.new
+ end
+
+ subject { described_class.new }
+
+ let(:job) { { 'jid' => 123 } }
+ let(:queue) { 'test_queue' }
+
+ describe '#call' do
+ it 'merges Application#logging_extras in to job' do
+ worker.log_extra_metadata_on_done(:key1, 15)
+ worker.log_extra_metadata_on_done(:key2, 16)
+ expect { |b| subject.call(worker, job, queue, &b) }.to yield_control
+
+ expect(job).to eq({ 'jid' => 123, 'extra.admin_email_worker.key1' => 15, 'extra.admin_email_worker.key2' => 16 })
+ end
+
+ it 'does not raise when the worker does not respond to #done_log_extra_metadata' do
+ expect { |b| subject.call(worker_without_application_worker, job, queue, &b) }.to yield_control
+
+ expect(job).to eq({ 'jid' => 123 })
+ end
+ end
+end
diff --git a/spec/lib/gitlab/sidekiq_middleware_spec.rb b/spec/lib/gitlab/sidekiq_middleware_spec.rb
index 752ec6a0a3f..6fe61fb42a5 100644
--- a/spec/lib/gitlab/sidekiq_middleware_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware_spec.rb
@@ -4,12 +4,17 @@ require 'spec_helper'
require 'sidekiq/testing'
describe Gitlab::SidekiqMiddleware do
- class TestWorker
- include Sidekiq::Worker
+ before do
+ stub_const('TestWorker', Class.new)
- def perform(_arg)
- Gitlab::SafeRequestStore['gitaly_call_actual'] = 1
- Gitlab::GitalyClient.query_time = 5
+ TestWorker.class_eval do
+ include Sidekiq::Worker
+ include ApplicationWorker
+
+ def perform(_arg)
+ Gitlab::SafeRequestStore['gitaly_call_actual'] = 1
+ Gitlab::GitalyClient.query_time = 5
+ end
end
end
@@ -32,8 +37,7 @@ describe Gitlab::SidekiqMiddleware do
described_class.server_configurator(
metrics: metrics,
arguments_logger: arguments_logger,
- memory_killer: memory_killer,
- request_store: request_store
+ memory_killer: memory_killer
).call(chain)
example.run
@@ -52,6 +56,7 @@ describe Gitlab::SidekiqMiddleware do
Gitlab::SidekiqMiddleware::ArgumentsLogger,
Gitlab::SidekiqMiddleware::MemoryKiller,
Gitlab::SidekiqMiddleware::RequestStoreMiddleware,
+ Gitlab::SidekiqMiddleware::ExtraDoneLogMetadata,
Gitlab::SidekiqMiddleware::WorkerContext::Server,
Gitlab::SidekiqMiddleware::AdminMode::Server,
Gitlab::SidekiqMiddleware::DuplicateJobs::Server
@@ -77,13 +82,11 @@ describe Gitlab::SidekiqMiddleware do
let(:metrics) { false }
let(:arguments_logger) { false }
let(:memory_killer) { false }
- let(:request_store) { false }
let(:disabled_sidekiq_middlewares) do
[
Gitlab::SidekiqMiddleware::ServerMetrics,
Gitlab::SidekiqMiddleware::ArgumentsLogger,
- Gitlab::SidekiqMiddleware::MemoryKiller,
- Gitlab::SidekiqMiddleware::RequestStoreMiddleware
+ Gitlab::SidekiqMiddleware::MemoryKiller
]
end
@@ -94,7 +97,6 @@ describe Gitlab::SidekiqMiddleware do
let(:metrics) { true }
let(:arguments_logger) { true }
let(:memory_killer) { true }
- let(:request_store) { true }
let(:disabled_sidekiq_middlewares) { [] }
it_behaves_like "a server middleware chain"
diff --git a/spec/lib/gitlab/snippet_search_results_spec.rb b/spec/lib/gitlab/snippet_search_results_spec.rb
index 47f26fdebe2..a41be0eaa95 100644
--- a/spec/lib/gitlab/snippet_search_results_spec.rb
+++ b/spec/lib/gitlab/snippet_search_results_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
describe Gitlab::SnippetSearchResults do
include SearchHelpers
- let!(:snippet) { create(:snippet, content: 'foo', file_name: 'foo') }
+ let_it_be(:snippet) { create(:snippet, content: 'foo', file_name: 'foo') }
let(:results) { described_class.new(snippet.author, 'foo') }
describe '#snippet_titles_count' do
@@ -14,27 +14,20 @@ describe Gitlab::SnippetSearchResults do
end
end
- describe '#snippet_blobs_count' do
- it 'returns the amount of matched snippet blobs' do
- expect(results.limited_snippet_blobs_count).to eq(1)
+ describe '#formatted_count' do
+ it 'returns the expected formatted count' do
+ expect(results).to receive(:limited_snippet_titles_count).and_return(1234)
+ expect(results.formatted_count('snippet_titles')).to eq(max_limited_count)
end
end
- describe '#formatted_count' do
- using RSpec::Parameterized::TableSyntax
-
- where(:scope, :count_method, :expected) do
- 'snippet_titles' | :limited_snippet_titles_count | max_limited_count
- 'snippet_blobs' | :limited_snippet_blobs_count | max_limited_count
- 'projects' | :limited_projects_count | max_limited_count
- 'unknown' | nil | nil
- end
+ describe '#objects' do
+ it 'uses page and per_page to paginate results' do
+ snippet2 = create(:snippet, :public, content: 'foo', file_name: 'foo')
- with_them do
- it 'returns the expected formatted count' do
- expect(results).to receive(count_method).and_return(1234) if count_method
- expect(results.formatted_count(scope)).to eq(expected)
- end
+ expect(results.objects('snippet_titles', page: 1, per_page: 1).to_a).to eq([snippet2])
+ expect(results.objects('snippet_titles', page: 2, per_page: 1).to_a).to eq([snippet])
+ expect(results.objects('snippet_titles', page: 1, per_page: 2).count).to eq(2)
end
end
end
diff --git a/spec/lib/gitlab/static_site_editor/config_spec.rb b/spec/lib/gitlab/static_site_editor/config_spec.rb
index 8f61476722d..a1db567db1a 100644
--- a/spec/lib/gitlab/static_site_editor/config_spec.rb
+++ b/spec/lib/gitlab/static_site_editor/config_spec.rb
@@ -5,9 +5,10 @@ require 'spec_helper'
describe Gitlab::StaticSiteEditor::Config do
subject(:config) { described_class.new(repository, ref, file_path, return_url) }
- let(:project) { create(:project, :public, :repository, name: 'project', namespace: namespace) }
- let(:namespace) { create(:namespace, name: 'namespace') }
- let(:repository) { project.repository }
+ let_it_be(:namespace) { create(:namespace, name: 'namespace') }
+ let_it_be(:project) { create(:project, :public, :repository, name: 'project', namespace: namespace) }
+ let_it_be(:repository) { project.repository }
+
let(:ref) { 'master' }
let(:file_path) { 'README.md' }
let(:return_url) { 'http://example.com' }
@@ -24,38 +25,45 @@ describe Gitlab::StaticSiteEditor::Config do
project: 'project',
project_id: project.id,
return_url: 'http://example.com',
- is_supported_content: true
+ is_supported_content: 'true',
+ base_url: '/namespace/project/-/sse/master%2FREADME.md'
)
end
+ context 'when file path is nested' do
+ let(:file_path) { 'lib/README.md' }
+
+ it { is_expected.to include(base_url: '/namespace/project/-/sse/master%2Flib%2FREADME.md') }
+ end
+
context 'when branch is not master' do
let(:ref) { 'my-branch' }
- it { is_expected.to include(is_supported_content: false) }
+ it { is_expected.to include(is_supported_content: 'false') }
end
context 'when file does not have a markdown extension' do
let(:file_path) { 'README.txt' }
- it { is_expected.to include(is_supported_content: false) }
+ it { is_expected.to include(is_supported_content: 'false') }
end
context 'when file does not have an extension' do
let(:file_path) { 'README' }
- it { is_expected.to include(is_supported_content: false) }
+ it { is_expected.to include(is_supported_content: 'false') }
end
context 'when file does not exist' do
let(:file_path) { 'UNKNOWN.md' }
- it { is_expected.to include(is_supported_content: false) }
+ it { is_expected.to include(is_supported_content: 'false') }
end
context 'when repository is empty' do
- let(:project) { create(:project_empty_repo) }
+ let(:repository) { create(:project_empty_repo).repository }
- it { is_expected.to include(is_supported_content: false) }
+ it { is_expected.to include(is_supported_content: 'false') }
end
end
end
diff --git a/spec/lib/gitlab/throttle_spec.rb b/spec/lib/gitlab/throttle_spec.rb
index 674646a5f06..e3679a1a721 100644
--- a/spec/lib/gitlab/throttle_spec.rb
+++ b/spec/lib/gitlab/throttle_spec.rb
@@ -6,82 +6,10 @@ describe Gitlab::Throttle do
describe '.protected_paths_enabled?' do
subject { described_class.protected_paths_enabled? }
- context 'when omnibus protected paths throttle should be used' do
- before do
- expect(described_class).to receive(:should_use_omnibus_protected_paths?).and_return(true)
- end
+ it 'returns Application Settings throttle_protected_paths_enabled?' do
+ expect(Gitlab::CurrentSettings.current_application_settings).to receive(:throttle_protected_paths_enabled?)
- it { is_expected.to be_falsey }
- end
-
- context 'when omnibus protected paths throttle should not be used' do
- before do
- expect(described_class).to receive(:should_use_omnibus_protected_paths?).and_return(false)
- end
-
- it 'returns Application Settings throttle_protected_paths_enabled?' do
- expect(Gitlab::CurrentSettings.current_application_settings).to receive(:throttle_protected_paths_enabled?)
-
- subject
- end
- end
- end
-
- describe '.should_use_omnibus_protected_paths?' do
- subject { described_class.should_use_omnibus_protected_paths? }
-
- context 'when rack_attack.admin_area_protected_paths_enabled config is unspecified' do
- context 'when the omnibus protected paths throttle has been recently used (it has data)' do
- before do
- expect(described_class).to receive(:omnibus_protected_paths_present?).and_return(true)
- end
-
- it { is_expected.to be_truthy }
- end
-
- context 'when the omnibus protected paths throttle has not been recently used' do
- before do
- expect(described_class).to receive(:omnibus_protected_paths_present?).and_return(false)
- end
-
- it { is_expected.to be_falsey }
- end
- end
-
- context 'when rack_attack.admin_area_protected_paths_enabled config is false' do
- before do
- stub_config(rack_attack: {
- admin_area_protected_paths_enabled: false
- })
- end
-
- context 'when the omnibus protected paths throttle has been recently used (it has data)' do
- before do
- expect(described_class).to receive(:omnibus_protected_paths_present?).and_return(true)
- end
-
- it { is_expected.to be_truthy }
- end
-
- context 'when the omnibus protected paths throttle has not been recently used' do
- before do
- expect(described_class).to receive(:omnibus_protected_paths_present?).and_return(false)
- end
-
- it { is_expected.to be_falsey }
- end
- end
-
- context 'when rack_attack.admin_area_protected_paths_enabled config is true' do
- before do
- stub_config(rack_attack: {
- admin_area_protected_paths_enabled: true
- })
-
- expect(described_class).not_to receive(:omnibus_protected_paths_present?)
- end
-
- it { is_expected.to be_falsey }
+ subject
end
end
end
diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb
index efb07d9dc95..2e65f98a085 100644
--- a/spec/lib/gitlab/tracking_spec.rb
+++ b/spec/lib/gitlab/tracking_spec.rb
@@ -28,17 +28,13 @@ describe Gitlab::Tracking do
end
it 'enables features using feature flags' do
- stub_feature_flags(additional_snowplow_tracking: true)
- allow(Feature).to receive(:enabled?).with(
- :additional_snowplow_tracking,
- '_group_'
- ).and_return(false)
+ stub_feature_flags(additional_snowplow_tracking: :__group__)
addition_feature_fields = {
formTracking: false,
linkClickTracking: false
}
- expect(subject.snowplow_options('_group_')).to include(addition_feature_fields)
+ expect(subject.snowplow_options(:_group_)).to include(addition_feature_fields)
end
end
diff --git a/spec/lib/gitlab/tree_summary_spec.rb b/spec/lib/gitlab/tree_summary_spec.rb
index d64b826ba9b..593b8655e80 100644
--- a/spec/lib/gitlab/tree_summary_spec.rb
+++ b/spec/lib/gitlab/tree_summary_spec.rb
@@ -8,12 +8,13 @@ describe Gitlab::TreeSummary do
let(:project) { create(:project, :empty_repo) }
let(:repo) { project.repository }
let(:commit) { repo.head_commit }
+ let_it_be(:user) { create(:user) }
let(:path) { nil }
let(:offset) { nil }
let(:limit) { nil }
- subject(:summary) { described_class.new(commit, project, path: path, offset: offset, limit: limit) }
+ subject(:summary) { described_class.new(commit, project, user, path: path, offset: offset, limit: limit) }
describe '#initialize' do
it 'defaults offset to 0' do
@@ -72,7 +73,8 @@ describe Gitlab::TreeSummary do
expected_commit_path = Gitlab::Routing.url_helpers.project_commit_path(project, commit)
expect(entry[:commit]).to be_a(::Commit)
- expect(entry[:commit_path]).to eq expected_commit_path
+ expect(entry[:commit_path]).to eq(expected_commit_path)
+ expect(entry[:commit_title_html]).to eq(commit.message)
end
context 'in a good subdirectory' do
@@ -140,6 +142,16 @@ describe Gitlab::TreeSummary do
expect(entry).to include(:commit)
end
end
+
+ context 'rendering commits' do
+ it 'does not perform N + 1 request' do
+ summary
+
+ queries = ActiveRecord::QueryRecorder.new { summary.summarize }
+
+ expect(queries.count).to be <= 3
+ end
+ end
end
describe '#more?' do
diff --git a/spec/lib/gitlab/url_builder_spec.rb b/spec/lib/gitlab/url_builder_spec.rb
index 1b23f331b89..66826bcb3b1 100644
--- a/spec/lib/gitlab/url_builder_spec.rb
+++ b/spec/lib/gitlab/url_builder_spec.rb
@@ -23,8 +23,9 @@ describe Gitlab::UrlBuilder do
:merge_request | ->(merge_request) { "/#{merge_request.project.full_path}/-/merge_requests/#{merge_request.iid}" }
:project_milestone | ->(milestone) { "/#{milestone.project.full_path}/-/milestones/#{milestone.iid}" }
:project_snippet | ->(snippet) { "/#{snippet.project.full_path}/snippets/#{snippet.id}" }
- :project_wiki | ->(wiki) { "/#{wiki.project.full_path}/-/wikis/home" }
+ :project_wiki | ->(wiki) { "/#{wiki.container.full_path}/-/wikis/home" }
:ci_build | ->(build) { "/#{build.project.full_path}/-/jobs/#{build.id}" }
+ :design | ->(design) { "/#{design.project.full_path}/-/design_management/designs/#{design.id}/raw_image" }
:group | ->(group) { "/groups/#{group.full_path}" }
:group_milestone | ->(milestone) { "/groups/#{milestone.group.full_path}/-/milestones/#{milestone.iid}" }
@@ -95,6 +96,16 @@ describe Gitlab::UrlBuilder do
end
end
+ context 'when passing a DesignManagement::Design' do
+ let(:design) { build_stubbed(:design) }
+
+ it 'uses the given ref and size in the URL' do
+ url = subject.build(design, ref: 'feature', size: 'small')
+
+ expect(url).to eq "#{Settings.gitlab['url']}/#{design.project.full_path}/-/design_management/designs/#{design.id}/feature/resized_image/small"
+ end
+ end
+
context 'when passing an unsupported class' do
let(:object) { Object.new }
diff --git a/spec/lib/gitlab/usage_data_counters/designs_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/designs_counter_spec.rb
new file mode 100644
index 00000000000..deaf7ebc7f3
--- /dev/null
+++ b/spec/lib/gitlab/usage_data_counters/designs_counter_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::UsageDataCounters::DesignsCounter do
+ it_behaves_like 'a redis usage counter', 'Designs', :create
+ it_behaves_like 'a redis usage counter', 'Designs', :update
+ it_behaves_like 'a redis usage counter', 'Designs', :delete
+
+ it_behaves_like 'a redis usage counter with totals', :design_management_designs,
+ create: 5,
+ update: 3,
+ delete: 2
+end
diff --git a/spec/lib/gitlab/usage_data_counters/web_ide_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/web_ide_counter_spec.rb
index 96ebeb8ff76..42abbecead0 100644
--- a/spec/lib/gitlab/usage_data_counters/web_ide_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/web_ide_counter_spec.rb
@@ -3,35 +3,35 @@
require 'spec_helper'
describe Gitlab::UsageDataCounters::WebIdeCounter, :clean_gitlab_redis_shared_state do
- shared_examples 'counter examples' do
+ shared_examples 'counter examples' do |event|
it 'increments counter and return the total count' do
- expect(described_class.public_send(total_counter_method)).to eq(0)
+ expect(described_class.public_send(:total_count, event)).to eq(0)
- 2.times { described_class.public_send(increment_counter_method) }
+ 2.times { described_class.public_send(:"increment_#{event}_count") }
- expect(described_class.public_send(total_counter_method)).to eq(2)
+ redis_key = "web_ide_#{event}_count".upcase
+ expect(described_class.public_send(:total_count, redis_key)).to eq(2)
end
end
describe 'commits counter' do
- let(:increment_counter_method) { :increment_commits_count }
- let(:total_counter_method) { :total_commits_count }
-
- it_behaves_like 'counter examples'
+ it_behaves_like 'counter examples', 'commits'
end
describe 'merge requests counter' do
- let(:increment_counter_method) { :increment_merge_requests_count }
- let(:total_counter_method) { :total_merge_requests_count }
-
- it_behaves_like 'counter examples'
+ it_behaves_like 'counter examples', 'merge_requests'
end
describe 'views counter' do
- let(:increment_counter_method) { :increment_views_count }
- let(:total_counter_method) { :total_views_count }
+ it_behaves_like 'counter examples', 'views'
+ end
- it_behaves_like 'counter examples'
+ describe 'terminals counter' do
+ it_behaves_like 'counter examples', 'terminals'
+ end
+
+ describe 'pipelines counter' do
+ it_behaves_like 'counter examples', 'pipelines'
end
describe 'previews counter' do
@@ -42,21 +42,19 @@ describe Gitlab::UsageDataCounters::WebIdeCounter, :clean_gitlab_redis_shared_st
end
context 'when web ide clientside preview is enabled' do
- let(:increment_counter_method) { :increment_previews_count }
- let(:total_counter_method) { :total_previews_count }
-
- it_behaves_like 'counter examples'
+ it_behaves_like 'counter examples', 'previews'
end
context 'when web ide clientside preview is not enabled' do
let(:setting_enabled) { false }
it 'does not increment the counter' do
- expect(described_class.total_previews_count).to eq(0)
+ redis_key = 'WEB_IDE_PREVIEWS_COUNT'
+ expect(described_class.total_count(redis_key)).to eq(0)
2.times { described_class.increment_previews_count }
- expect(described_class.total_previews_count).to eq(0)
+ expect(described_class.total_count(redis_key)).to eq(0)
end
end
end
@@ -66,6 +64,8 @@ describe Gitlab::UsageDataCounters::WebIdeCounter, :clean_gitlab_redis_shared_st
merge_requests = 3
views = 2
previews = 4
+ terminals = 1
+ pipelines = 2
before do
stub_application_setting(web_ide_clientside_preview_enabled: true)
@@ -74,6 +74,8 @@ describe Gitlab::UsageDataCounters::WebIdeCounter, :clean_gitlab_redis_shared_st
merge_requests.times { described_class.increment_merge_requests_count }
views.times { described_class.increment_views_count }
previews.times { described_class.increment_previews_count }
+ terminals.times { described_class.increment_terminals_count }
+ pipelines.times { described_class.increment_pipelines_count }
end
it 'can report all totals' do
@@ -81,7 +83,8 @@ describe Gitlab::UsageDataCounters::WebIdeCounter, :clean_gitlab_redis_shared_st
web_ide_commits: commits,
web_ide_views: views,
web_ide_merge_requests: merge_requests,
- web_ide_previews: previews
+ web_ide_previews: previews,
+ web_ide_terminals: terminals
)
end
end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index a46778bb6c3..9c6aab10083 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -7,6 +7,8 @@ describe Gitlab::UsageData, :aggregate_failures do
before do
allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false)
+
+ stub_object_store_settings
end
shared_examples "usage data execution" do
@@ -42,6 +44,9 @@ describe Gitlab::UsageData, :aggregate_failures do
expect(count_data[:projects_jira_active]).to eq(4)
expect(count_data[:projects_jira_server_active]).to eq(2)
expect(count_data[:projects_jira_cloud_active]).to eq(2)
+ expect(count_data[:jira_imports_projects_count]).to eq(2)
+ expect(count_data[:jira_imports_total_imported_count]).to eq(3)
+ expect(count_data[:jira_imports_total_imported_issues_count]).to eq(13)
expect(count_data[:projects_slack_notifications_active]).to eq(2)
expect(count_data[:projects_slack_slash_active]).to eq(1)
expect(count_data[:projects_slack_active]).to eq(2)
@@ -57,6 +62,9 @@ describe Gitlab::UsageData, :aggregate_failures do
expect(count_data[:issues_using_zoom_quick_actions]).to eq(3)
expect(count_data[:issues_with_embedded_grafana_charts_approx]).to eq(2)
expect(count_data[:incident_issues]).to eq(4)
+ expect(count_data[:issues_created_gitlab_alerts]).to eq(1)
+ expect(count_data[:alert_bot_incident_issues]).to eq(4)
+ expect(count_data[:incident_labeled_issues]).to eq(3)
expect(count_data[:clusters_enabled]).to eq(6)
expect(count_data[:project_clusters_enabled]).to eq(4)
@@ -82,6 +90,56 @@ describe Gitlab::UsageData, :aggregate_failures do
expect(count_data[:clusters_management_project]).to eq(1)
end
+ it 'gathers object store usage correctly' do
+ expect(subject[:object_store]).to eq(
+ { artifacts: { enabled: true, object_store: { enabled: true, direct_upload: true, background_upload: false, provider: "AWS" } },
+ external_diffs: { enabled: false },
+ lfs: { enabled: true, object_store: { enabled: false, direct_upload: true, background_upload: false, provider: "AWS" } },
+ uploads: { enabled: nil, object_store: { enabled: false, direct_upload: true, background_upload: false, provider: "AWS" } },
+ packages: { enabled: true, object_store: { enabled: false, direct_upload: false, background_upload: true, provider: "AWS" } } }
+ )
+ 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) }
+
+ %i[keep_n cadence older_than].each do |attribute|
+ ContainerExpirationPolicy.send("#{attribute}_options").keys.each do |value|
+ let_it_be("container_expiration_policy_with_#{attribute}_set_to_#{value}") { create(:container_expiration_policy, attribute => value) }
+ end
+ end
+
+ let(:inactive_policies) { ::ContainerExpirationPolicy.where(enabled: false) }
+ let(:active_policies) { ::ContainerExpirationPolicy.active }
+
+ subject { described_class.data[:counts] }
+
+ it 'gathers usage data' do
+ expect(subject[:projects_with_expiration_policy_enabled]).to eq 20
+ expect(subject[:projects_with_expiration_policy_disabled]).to eq 1
+
+ expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_unset]).to eq 14
+ expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_1]).to eq 1
+ expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_5]).to eq 1
+ expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_10]).to eq 1
+ expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_25]).to eq 1
+ expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_50]).to eq 1
+
+ expect(subject[:projects_with_expiration_policy_enabled_with_older_than_unset]).to eq 16
+ expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_7d]).to eq 1
+ expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_14d]).to eq 1
+ expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_30d]).to eq 1
+ expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_90d]).to eq 1
+
+ expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_1d]).to eq 12
+ expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_7d]).to eq 5
+ expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_14d]).to eq 1
+ expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_1month]).to eq 1
+ expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_3month]).to eq 1
+ end
+ end
+
it 'works when queries time out' do
allow_any_instance_of(ActiveRecord::Relation)
.to receive(:count).and_raise(ActiveRecord::StatementInvalid.new(''))
@@ -101,6 +159,7 @@ describe Gitlab::UsageData, :aggregate_failures do
subject { described_class.usage_data_counters }
it { is_expected.to all(respond_to :totals) }
+ it { is_expected.to all(respond_to :fallback_totals) }
describe 'the results of calling #totals on all objects in the array' do
subject { described_class.usage_data_counters.map(&:totals) }
@@ -109,6 +168,13 @@ describe Gitlab::UsageData, :aggregate_failures do
it { is_expected.to all(have_attributes(keys: all(be_a Symbol), values: all(be_a Integer))) }
end
+ describe 'the results of calling #fallback_totals on all objects in the array' do
+ subject { described_class.usage_data_counters.map(&:fallback_totals) }
+
+ it { is_expected.to all(be_a Hash) }
+ it { is_expected.to all(have_attributes(keys: all(be_a Symbol), values: all(eq(-1)))) }
+ end
+
it 'does not have any conflicts' do
all_keys = subject.flat_map { |counter| counter.totals.keys }
@@ -128,6 +194,14 @@ describe Gitlab::UsageData, :aggregate_failures do
end
end
+ describe '.recording_ce_finished_at' do
+ subject { described_class.recording_ce_finish_data }
+
+ it 'gathers time ce recording finishes at' do
+ expect(subject[:recording_ce_finished_at]).to be_a(Time)
+ end
+ end
+
context 'when not relying on database records' do
describe '#features_usage_data_ce' do
subject { described_class.features_usage_data_ce }
@@ -143,42 +217,20 @@ describe Gitlab::UsageData, :aggregate_failures do
expect(subject[:dependency_proxy_enabled]).to eq(Gitlab.config.dependency_proxy.enabled)
expect(subject[:gitlab_shared_runners_enabled]).to eq(Gitlab.config.gitlab_ci.shared_runners_enabled)
expect(subject[:web_ide_clientside_preview_enabled]).to eq(Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?)
+ expect(subject[:grafana_link_enabled]).to eq(Gitlab::CurrentSettings.grafana_enabled?)
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) }
- %i[keep_n cadence older_than].each do |attribute|
- ContainerExpirationPolicy.send("#{attribute}_options").keys.each do |value|
- let_it_be("container_expiration_policy_with_#{attribute}_set_to_#{value}") { create(:container_expiration_policy, attribute => value) }
- end
+ context 'with embedded grafana' do
+ it 'returns true when embedded grafana is enabled' do
+ stub_application_setting(grafana_enabled: true)
+
+ expect(subject[:grafana_link_enabled]).to eq(true)
end
- let(:inactive_policies) { ::ContainerExpirationPolicy.where(enabled: false) }
- let(:active_policies) { ::ContainerExpirationPolicy.active }
-
- it 'gathers usage data' do
- expect(subject[:projects_with_expiration_policy_enabled]).to eq 16
- expect(subject[:projects_with_expiration_policy_disabled]).to eq 1
-
- expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_unset]).to eq 10
- expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_1]).to eq 1
- expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_5]).to eq 1
- expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_10]).to eq 1
- expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_25]).to eq 1
- expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_50]).to eq 1
-
- expect(subject[:projects_with_expiration_policy_enabled_with_older_than_unset]).to eq 12
- expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_7d]).to eq 1
- expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_14d]).to eq 1
- expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_30d]).to eq 1
- expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_90d]).to eq 1
-
- expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_1d]).to eq 12
- expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_7d]).to eq 1
- expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_14d]).to eq 1
- expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_1month]).to eq 1
- expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_3month]).to eq 1
+ it 'returns false when embedded grafana is disabled' do
+ stub_application_setting(grafana_enabled: false)
+
+ expect(subject[:grafana_link_enabled]).to eq(false)
end
end
end
@@ -223,6 +275,66 @@ describe Gitlab::UsageData, :aggregate_failures do
end
end
+ describe '#object_store_config' do
+ let(:component) { 'lfs' }
+
+ subject { described_class.object_store_config(component) }
+
+ context 'when object_store is not configured' do
+ it 'returns component enable status only' do
+ allow(Settings).to receive(:[]).with(component).and_return({ 'enabled' => false })
+
+ expect(subject).to eq({ enabled: false })
+ end
+ end
+
+ context 'when object_store is configured' do
+ it 'returns filtered object store config' do
+ allow(Settings).to receive(:[]).with(component)
+ .and_return(
+ { 'enabled' => true,
+ 'object_store' =>
+ { 'enabled' => true,
+ 'remote_directory' => component,
+ 'direct_upload' => true,
+ 'connection' =>
+ { 'provider' => 'AWS', 'aws_access_key_id' => 'minio', 'aws_secret_access_key' => 'gdk-minio', 'region' => 'gdk', 'endpoint' => 'http://127.0.0.1:9000', 'path_style' => true },
+ 'background_upload' => false,
+ 'proxy_download' => false } })
+
+ expect(subject).to eq(
+ { enabled: true, object_store: { enabled: true, direct_upload: true, background_upload: false, provider: "AWS" } })
+ end
+ end
+
+ context 'when retrieve component setting meets exception' do
+ it 'returns -1 for component enable status' do
+ allow(Settings).to receive(:[]).with(component).and_raise(StandardError)
+
+ expect(subject).to eq({ enabled: -1 })
+ end
+ end
+ end
+
+ describe '#object_store_usage_data' do
+ subject { described_class.object_store_usage_data }
+
+ it 'fetches object store config of five components' do
+ %w(artifacts external_diffs lfs uploads packages).each do |component|
+ expect(described_class).to receive(:object_store_config).with(component).and_return("#{component}_object_store_config")
+ end
+
+ expect(subject).to eq(
+ object_store: {
+ artifacts: 'artifacts_object_store_config',
+ external_diffs: 'external_diffs_object_store_config',
+ lfs: 'lfs_object_store_config',
+ uploads: 'uploads_object_store_config',
+ packages: 'packages_object_store_config'
+ })
+ end
+ end
+
describe '#cycle_analytics_usage_data' do
subject { described_class.cycle_analytics_usage_data }
@@ -244,18 +356,132 @@ describe Gitlab::UsageData, :aggregate_failures do
describe '#ingress_modsecurity_usage' do
subject { described_class.ingress_modsecurity_usage }
- it 'gathers variable data' do
- allow_any_instance_of(
- ::Clusters::Applications::IngressModsecurityUsageService
- ).to receive(:execute).and_return(
- {
- ingress_modsecurity_blocking: 1,
- ingress_modsecurity_disabled: 2
- }
- )
-
- expect(subject[:ingress_modsecurity_blocking]).to eq(1)
- expect(subject[:ingress_modsecurity_disabled]).to eq(2)
+ let(:environment) { create(:environment) }
+ let(:project) { environment.project }
+ let(:environment_scope) { '*' }
+ let(:deployment) { create(:deployment, :success, environment: environment, project: project, cluster: cluster) }
+ let(:cluster) { create(:cluster, environment_scope: environment_scope, projects: [project]) }
+ let(:ingress_mode) { :modsecurity_blocking }
+ let!(:ingress) { create(:clusters_applications_ingress, ingress_mode, cluster: cluster) }
+
+ context 'when cluster is disabled' do
+ let(:cluster) { create(:cluster, :disabled, projects: [project]) }
+
+ it 'gathers ingress data' do
+ expect(subject[:ingress_modsecurity_logging]).to eq(0)
+ expect(subject[:ingress_modsecurity_blocking]).to eq(0)
+ expect(subject[:ingress_modsecurity_disabled]).to eq(0)
+ expect(subject[:ingress_modsecurity_not_installed]).to eq(0)
+ end
+ end
+
+ context 'when deployment is unsuccessful' do
+ let!(:deployment) { create(:deployment, :failed, environment: environment, project: project, cluster: cluster) }
+
+ it 'gathers ingress data' do
+ expect(subject[:ingress_modsecurity_logging]).to eq(0)
+ expect(subject[:ingress_modsecurity_blocking]).to eq(0)
+ expect(subject[:ingress_modsecurity_disabled]).to eq(0)
+ expect(subject[:ingress_modsecurity_not_installed]).to eq(0)
+ end
+ end
+
+ context 'when deployment is successful' do
+ let!(:deployment) { create(:deployment, :success, environment: environment, project: project, cluster: cluster) }
+
+ context 'when modsecurity is in blocking mode' do
+ it 'gathers ingress data' do
+ expect(subject[:ingress_modsecurity_logging]).to eq(0)
+ expect(subject[:ingress_modsecurity_blocking]).to eq(1)
+ expect(subject[:ingress_modsecurity_disabled]).to eq(0)
+ expect(subject[:ingress_modsecurity_not_installed]).to eq(0)
+ end
+ end
+
+ context 'when modsecurity is in logging mode' do
+ let(:ingress_mode) { :modsecurity_logging }
+
+ it 'gathers ingress data' do
+ expect(subject[:ingress_modsecurity_logging]).to eq(1)
+ expect(subject[:ingress_modsecurity_blocking]).to eq(0)
+ expect(subject[:ingress_modsecurity_disabled]).to eq(0)
+ expect(subject[:ingress_modsecurity_not_installed]).to eq(0)
+ end
+ end
+
+ context 'when modsecurity is disabled' do
+ let(:ingress_mode) { :modsecurity_disabled }
+
+ it 'gathers ingress data' do
+ expect(subject[:ingress_modsecurity_logging]).to eq(0)
+ expect(subject[:ingress_modsecurity_blocking]).to eq(0)
+ expect(subject[:ingress_modsecurity_disabled]).to eq(1)
+ expect(subject[:ingress_modsecurity_not_installed]).to eq(0)
+ end
+ end
+
+ context 'when modsecurity is not installed' do
+ let(:ingress_mode) { :modsecurity_not_installed }
+
+ it 'gathers ingress data' do
+ expect(subject[:ingress_modsecurity_logging]).to eq(0)
+ expect(subject[:ingress_modsecurity_blocking]).to eq(0)
+ expect(subject[:ingress_modsecurity_disabled]).to eq(0)
+ expect(subject[:ingress_modsecurity_not_installed]).to eq(1)
+ end
+ end
+
+ context 'with multiple projects' do
+ let(:environment_2) { create(:environment) }
+ let(:project_2) { environment_2.project }
+ let(:cluster_2) { create(:cluster, environment_scope: environment_scope, projects: [project_2]) }
+ let!(:ingress_2) { create(:clusters_applications_ingress, :modsecurity_logging, cluster: cluster_2) }
+ let!(:deployment_2) { create(:deployment, :success, environment: environment_2, project: project_2, cluster: cluster_2) }
+
+ it 'gathers non-duplicated ingress data' do
+ expect(subject[:ingress_modsecurity_logging]).to eq(1)
+ expect(subject[:ingress_modsecurity_blocking]).to eq(1)
+ expect(subject[:ingress_modsecurity_disabled]).to eq(0)
+ expect(subject[:ingress_modsecurity_not_installed]).to eq(0)
+ end
+ end
+
+ context 'with multiple deployments' do
+ let!(:deployment_2) { create(:deployment, :success, environment: environment, project: project, cluster: cluster) }
+
+ it 'gathers non-duplicated ingress data' do
+ expect(subject[:ingress_modsecurity_logging]).to eq(0)
+ expect(subject[:ingress_modsecurity_blocking]).to eq(1)
+ expect(subject[:ingress_modsecurity_disabled]).to eq(0)
+ expect(subject[:ingress_modsecurity_not_installed]).to eq(0)
+ end
+ end
+
+ context 'with multiple projects' do
+ let(:environment_2) { create(:environment) }
+ let(:project_2) { environment_2.project }
+ let!(:deployment_2) { create(:deployment, :success, environment: environment_2, project: project_2, cluster: cluster) }
+ let(:cluster) { create(:cluster, environment_scope: environment_scope, projects: [project, project_2]) }
+
+ it 'gathers ingress data' do
+ expect(subject[:ingress_modsecurity_logging]).to eq(0)
+ expect(subject[:ingress_modsecurity_blocking]).to eq(2)
+ expect(subject[:ingress_modsecurity_disabled]).to eq(0)
+ expect(subject[:ingress_modsecurity_not_installed]).to eq(0)
+ end
+ end
+
+ context 'with multiple environments' do
+ let!(:environment_2) { create(:environment, project: project) }
+ let!(:deployment_2) { create(:deployment, :success, environment: environment_2, project: project, cluster: cluster) }
+
+ it 'gathers ingress data' do
+ expect(subject[:ingress_modsecurity_logging]).to eq(0)
+ expect(subject[:ingress_modsecurity_blocking]).to eq(2)
+ expect(subject[:ingress_modsecurity_disabled]).to eq(0)
+ expect(subject[:ingress_modsecurity_not_installed]).to eq(0)
+ end
+ end
end
end
@@ -334,9 +560,10 @@ describe Gitlab::UsageData, :aggregate_failures do
end
it 'returns the fallback value when counting fails' do
+ stub_const("Gitlab::UsageData::FALLBACK", 15)
allow(relation).to receive(:count).and_raise(ActiveRecord::StatementInvalid.new(''))
- expect(described_class.count(relation, fallback: 15, batch: false)).to eq(15)
+ expect(described_class.count(relation, batch: false)).to eq(15)
end
end
@@ -350,9 +577,10 @@ describe Gitlab::UsageData, :aggregate_failures do
end
it 'returns the fallback value when counting fails' do
+ stub_const("Gitlab::UsageData::FALLBACK", 15)
allow(relation).to receive(:distinct_count_by).and_raise(ActiveRecord::StatementInvalid.new(''))
- expect(described_class.distinct_count(relation, fallback: 15, batch: false)).to eq(15)
+ expect(described_class.distinct_count(relation, batch: false)).to eq(15)
end
end
end
@@ -387,4 +615,28 @@ describe Gitlab::UsageData, :aggregate_failures do
expect(described_class.alt_usage_data(1)).to eq 1
end
end
+
+ describe '#redis_usage_data' do
+ context 'with block given' do
+ it 'returns the fallback when it gets an error' do
+ expect(described_class.redis_usage_data { raise ::Redis::CommandError } ).to eq(-1)
+ end
+
+ it 'returns the evaluated block when given' do
+ expect(described_class.redis_usage_data { 1 }).to eq(1)
+ end
+ end
+
+ context 'with counter given' do
+ it 'returns the falback values for all counter keys when it gets an error' do
+ allow(::Gitlab::UsageDataCounters::WikiPageCounter).to receive(:totals).and_raise(::Redis::CommandError)
+ expect(described_class.redis_usage_data(::Gitlab::UsageDataCounters::WikiPageCounter)).to eql(::Gitlab::UsageDataCounters::WikiPageCounter.fallback_totals)
+ end
+
+ it 'returns the totals when couter is given' do
+ allow(::Gitlab::UsageDataCounters::WikiPageCounter).to receive(:totals).and_return({ wiki_pages_create: 2 })
+ expect(described_class.redis_usage_data(::Gitlab::UsageDataCounters::WikiPageCounter)).to eql({ wiki_pages_create: 2 })
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/user_access_snippet_spec.rb b/spec/lib/gitlab/user_access_snippet_spec.rb
index 57e52e2e93d..2e8a0a49a76 100644
--- a/spec/lib/gitlab/user_access_snippet_spec.rb
+++ b/spec/lib/gitlab/user_access_snippet_spec.rb
@@ -7,6 +7,8 @@ describe Gitlab::UserAccessSnippet do
let_it_be(:project) { create(:project, :private) }
let_it_be(:snippet) { create(:project_snippet, :private, project: project) }
+ let_it_be(:migration_bot) { User.migration_bot }
+
let(:user) { create(:user) }
describe '#can_do_action?' do
@@ -36,6 +38,14 @@ describe Gitlab::UserAccessSnippet do
expect(access.can_do_action?(:ability)).to eq(false)
end
end
+
+ context 'when user is migration bot' do
+ let(:user) { migration_bot }
+
+ it 'allows access' do
+ expect(access.can_do_action?(:ability)).to eq(true)
+ end
+ end
end
describe '#can_push_to_branch?' do
@@ -65,6 +75,16 @@ describe Gitlab::UserAccessSnippet do
end
end
+ context 'when user is migration bot' do
+ let(:user) { migration_bot }
+
+ it 'allows access' do
+ allow(Ability).to receive(:allowed?).and_return(false)
+
+ expect(access.can_push_to_branch?('random_branch')).to eq(true)
+ end
+ end
+
context 'when snippet is nil' do
let(:user) { create_user_from_membership(project, :admin) }
let(:snippet) { nil }
@@ -72,6 +92,14 @@ describe Gitlab::UserAccessSnippet do
it 'disallows access' do
expect(access.can_push_to_branch?('random_branch')).to eq(false)
end
+
+ context 'when user is migration bot' do
+ let(:user) { migration_bot }
+
+ it 'disallows access' do
+ expect(access.can_push_to_branch?('random_branch')).to eq(false)
+ end
+ end
end
end
@@ -79,17 +107,41 @@ describe Gitlab::UserAccessSnippet do
it 'returns false' do
expect(access.can_create_tag?('random_tag')).to be_falsey
end
+
+ context 'when user is migration bot' do
+ let(:user) { migration_bot }
+
+ it 'returns false' do
+ expect(access.can_create_tag?('random_tag')).to be_falsey
+ end
+ end
end
describe '#can_delete_branch?' do
it 'returns false' do
expect(access.can_delete_branch?('random_branch')).to be_falsey
end
+
+ context 'when user is migration bot' do
+ let(:user) { migration_bot }
+
+ it 'returns false' do
+ expect(access.can_delete_branch?('random_branch')).to be_falsey
+ end
+ end
end
describe '#can_merge_to_branch?' do
it 'returns false' do
expect(access.can_merge_to_branch?('random_branch')).to be_falsey
end
+
+ context 'when user is migration bot' do
+ let(:user) { migration_bot }
+
+ it 'returns false' do
+ expect(access.can_merge_to_branch?('random_branch')).to be_falsey
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/utils/measuring_spec.rb b/spec/lib/gitlab/utils/measuring_spec.rb
new file mode 100644
index 00000000000..254f53f7da3
--- /dev/null
+++ b/spec/lib/gitlab/utils/measuring_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+describe Gitlab::Utils::Measuring do
+ describe '#with_measuring' do
+ let(:base_log_data) { {} }
+ let(:result) { "result" }
+
+ before do
+ allow(ActiveSupport::Logger).to receive(:logger_outputs_to?).with(Gitlab::Utils::Measuring.logger, STDOUT).and_return(false)
+ end
+
+ let(:measurement) { described_class.new(base_log_data) }
+
+ subject do
+ measurement.with_measuring { result }
+ end
+
+ it 'measures and logs data', :aggregate_failure do
+ expect(measurement).to receive(:with_measure_time).and_call_original
+ expect(measurement).to receive(:with_count_queries).and_call_original
+ expect(measurement).to receive(:with_gc_stats).and_call_original
+
+ expect(described_class.logger).to receive(:info).with(include(:gc_stats, :time_to_finish, :number_of_sql_calls, :memory_usage, :label))
+
+ is_expected.to eq(result)
+ end
+
+ context 'with base_log_data provided' do
+ let(:base_log_data) { { test: "data" } }
+
+ it 'logs includes base data' do
+ expect(described_class.logger).to receive(:info).with(include(:test, :gc_stats, :time_to_finish, :number_of_sql_calls, :memory_usage, :label))
+
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb
index e34367cbbf9..0f0d6a93c97 100644
--- a/spec/lib/gitlab/utils_spec.rb
+++ b/spec/lib/gitlab/utils_spec.rb
@@ -59,9 +59,10 @@ describe Gitlab::Utils do
using RSpec::Parameterized::TableSyntax
where(:original, :expected) do
- 1999.8999 | 2
- 12384 | 12.38
- 333 | 0.33
+ 1999.8999 | 1.9999
+ 12384 | 12.384
+ 333 | 0.333
+ 1333.33333333 | 1.333333
end
with_them do
@@ -129,7 +130,7 @@ describe Gitlab::Utils do
expect(to_boolean(false)).to be(false)
end
- it 'converts a valid string to a boolean' do
+ it 'converts a valid value to a boolean' do
expect(to_boolean(true)).to be(true)
expect(to_boolean('true')).to be(true)
expect(to_boolean('YeS')).to be(true)
@@ -145,12 +146,35 @@ describe Gitlab::Utils do
expect(to_boolean('oFF')).to be(false)
end
- it 'converts an invalid string to nil' do
+ it 'converts an invalid value to nil' do
expect(to_boolean('fals')).to be_nil
expect(to_boolean('yeah')).to be_nil
expect(to_boolean('')).to be_nil
expect(to_boolean(nil)).to be_nil
end
+
+ it 'accepts a default value, and does not return it when a valid value is given' do
+ expect(to_boolean(true, default: false)).to be(true)
+ expect(to_boolean('true', default: false)).to be(true)
+ expect(to_boolean('YeS', default: false)).to be(true)
+ expect(to_boolean('t', default: false)).to be(true)
+ expect(to_boolean('1', default: 'any value')).to be(true)
+ expect(to_boolean('ON', default: 42)).to be(true)
+
+ expect(to_boolean('FaLse', default: true)).to be(false)
+ expect(to_boolean('F', default: true)).to be(false)
+ expect(to_boolean('NO', default: true)).to be(false)
+ expect(to_boolean('n', default: true)).to be(false)
+ expect(to_boolean('0', default: 'any value')).to be(false)
+ expect(to_boolean('oFF', default: 42)).to be(false)
+ end
+
+ it 'accepts a default value, and returns it when an invalid value is given' do
+ expect(to_boolean('fals', default: true)).to eq(true)
+ expect(to_boolean('yeah', default: false)).to eq(false)
+ expect(to_boolean('', default: 'any value')).to eq('any value')
+ expect(to_boolean(nil, default: 42)).to eq(42)
+ end
end
describe '.boolean_to_yes_no' do
diff --git a/spec/lib/gitlab/view/presenter/factory_spec.rb b/spec/lib/gitlab/view/presenter/factory_spec.rb
index 515a1b0a8e4..7bf3c325019 100644
--- a/spec/lib/gitlab/view/presenter/factory_spec.rb
+++ b/spec/lib/gitlab/view/presenter/factory_spec.rb
@@ -31,11 +31,11 @@ describe Gitlab::View::Presenter::Factory do
end
it 'uses the presenter_class if given on #initialize' do
- MyCustomPresenter = Class.new(described_class)
+ my_custom_presenter = Class.new(described_class)
- presenter = described_class.new(build, presenter_class: MyCustomPresenter).fabricate!
+ presenter = described_class.new(build, presenter_class: my_custom_presenter).fabricate!
- expect(presenter).to be_a(MyCustomPresenter)
+ expect(presenter).to be_a(my_custom_presenter)
end
end
end
diff --git a/spec/lib/gitlab/wiki_pages/front_matter_parser_spec.rb b/spec/lib/gitlab/wiki_pages/front_matter_parser_spec.rb
index c606ba11b9c..f9ed769f2d9 100644
--- a/spec/lib/gitlab/wiki_pages/front_matter_parser_spec.rb
+++ b/spec/lib/gitlab/wiki_pages/front_matter_parser_spec.rb
@@ -76,11 +76,7 @@ describe Gitlab::WikiPages::FrontMatterParser do
let(:raw_content) { with_front_matter }
before do
- stub_feature_flags(Gitlab::WikiPages::FrontMatterParser::FEATURE_FLAG => false)
- stub_feature_flags(Gitlab::WikiPages::FrontMatterParser::FEATURE_FLAG => {
- enabled: true,
- thing: gate
- })
+ stub_feature_flags(Gitlab::WikiPages::FrontMatterParser::FEATURE_FLAG => gate)
end
it do
diff --git a/spec/lib/gitlab/with_request_store_spec.rb b/spec/lib/gitlab/with_request_store_spec.rb
new file mode 100644
index 00000000000..1ef8d986f96
--- /dev/null
+++ b/spec/lib/gitlab/with_request_store_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'request_store'
+
+describe Gitlab::WithRequestStore do
+ let(:fake_class) { Class.new { include Gitlab::WithRequestStore } }
+
+ subject(:object) { fake_class.new }
+
+ describe "#with_request_store" do
+ it 'starts a request store and yields control' do
+ expect(RequestStore).to receive(:begin!).ordered
+ expect(RequestStore).to receive(:end!).ordered
+ expect(RequestStore).to receive(:clear!).ordered
+
+ expect { |b| object.with_request_store(&b) }.to yield_control
+ end
+
+ it 'only starts a request store once when nested' do
+ expect(RequestStore).to receive(:begin!).ordered.once.and_call_original
+ expect(RequestStore).to receive(:end!).ordered.once.and_call_original
+ expect(RequestStore).to receive(:clear!).ordered.once.and_call_original
+
+ object.with_request_store do
+ expect { |b| object.with_request_store(&b) }.to yield_control
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb
index 921ed568b71..53b6f461a48 100644
--- a/spec/lib/gitlab/workhorse_spec.rb
+++ b/spec/lib/gitlab/workhorse_spec.rb
@@ -9,7 +9,7 @@ describe Gitlab::Workhorse do
def decode_workhorse_header(array)
key, value = array
command, encoded_params = value.split(":")
- params = JSON.parse(Base64.urlsafe_decode64(encoded_params))
+ params = Gitlab::Json.parse(Base64.urlsafe_decode64(encoded_params))
[key, command, params]
end
@@ -24,7 +24,7 @@ describe Gitlab::Workhorse do
let(:ref) { 'master' }
let(:format) { 'zip' }
let(:storage_path) { Gitlab.config.gitlab.repository_downloads_path }
- let(:path) { 'some/path' if Feature.enabled?(:git_archive_path, default_enabled: true) }
+ let(:path) { 'some/path' }
let(:metadata) { repository.archive_metadata(ref, storage_path, format, append_sha: nil, path: path) }
let(:cache_disabled) { false }
@@ -36,70 +36,36 @@ describe Gitlab::Workhorse do
allow(described_class).to receive(:git_archive_cache_disabled?).and_return(cache_disabled)
end
- context 'feature flag disabled' do
- before do
- stub_feature_flags(git_archive_path: false)
- end
-
- it 'sets the header correctly' do
- key, command, params = decode_workhorse_header(subject)
+ it 'sets the header correctly' do
+ key, command, params = decode_workhorse_header(subject)
- expected_params = metadata.merge(
- 'GitalyRepository' => repository.gitaly_repository.to_h,
- 'GitalyServer' => {
- features: { 'gitaly-feature-foobar' => 'true' },
- address: Gitlab::GitalyClient.address(project.repository_storage),
- token: Gitlab::GitalyClient.token(project.repository_storage)
- }
+ expect(key).to eq('Gitlab-Workhorse-Send-Data')
+ expect(command).to eq('git-archive')
+ expect(params).to eq({
+ 'GitalyServer' => {
+ features: { 'gitaly-feature-foobar' => 'true' },
+ address: Gitlab::GitalyClient.address(project.repository_storage),
+ token: Gitlab::GitalyClient.token(project.repository_storage)
+ },
+ 'ArchivePath' => metadata['ArchivePath'],
+ 'GetArchiveRequest' => Base64.encode64(
+ Gitaly::GetArchiveRequest.new(
+ repository: repository.gitaly_repository,
+ commit_id: metadata['CommitId'],
+ prefix: metadata['ArchivePrefix'],
+ format: Gitaly::GetArchiveRequest::Format::ZIP,
+ path: path
+ ).to_proto
)
-
- expect(key).to eq('Gitlab-Workhorse-Send-Data')
- expect(command).to eq('git-archive')
- expect(params).to eq(expected_params.deep_stringify_keys)
- end
-
- context 'when archive caching is disabled' do
- let(:cache_disabled) { true }
-
- it 'tells workhorse not to use the cache' do
- _, _, params = decode_workhorse_header(subject)
- expect(params).to include({ 'DisableCache' => true })
- end
- end
+ }.deep_stringify_keys)
end
- context 'feature flag enabled' do
- it 'sets the header correctly' do
- key, command, params = decode_workhorse_header(subject)
-
- expect(key).to eq('Gitlab-Workhorse-Send-Data')
- expect(command).to eq('git-archive')
- expect(params).to eq({
- 'GitalyServer' => {
- features: { 'gitaly-feature-foobar' => 'true' },
- address: Gitlab::GitalyClient.address(project.repository_storage),
- token: Gitlab::GitalyClient.token(project.repository_storage)
- },
- 'ArchivePath' => metadata['ArchivePath'],
- 'GetArchiveRequest' => Base64.encode64(
- Gitaly::GetArchiveRequest.new(
- repository: repository.gitaly_repository,
- commit_id: metadata['CommitId'],
- prefix: metadata['ArchivePrefix'],
- format: Gitaly::GetArchiveRequest::Format::ZIP,
- path: path
- ).to_proto
- )
- }.deep_stringify_keys)
- end
-
- context 'when archive caching is disabled' do
- let(:cache_disabled) { true }
+ context 'when archive caching is disabled' do
+ let(:cache_disabled) { true }
- it 'tells workhorse not to use the cache' do
- _, _, params = decode_workhorse_header(subject)
- expect(params).to include({ 'DisableCache' => true })
- end
+ it 'tells workhorse not to use the cache' do
+ _, _, params = decode_workhorse_header(subject)
+ expect(params).to include({ 'DisableCache' => true })
end
end
diff --git a/spec/lib/gitlab/x509/signature_spec.rb b/spec/lib/gitlab/x509/signature_spec.rb
index 6c585acd5cd..cff2fd7748b 100644
--- a/spec/lib/gitlab/x509/signature_spec.rb
+++ b/spec/lib/gitlab/x509/signature_spec.rb
@@ -229,4 +229,164 @@ describe Gitlab::X509::Signature do
end
end
end
+
+ describe '#user' do
+ signature = described_class.new(
+ X509Helpers::User1.signed_tag_signature,
+ X509Helpers::User1.signed_tag_base_data,
+ X509Helpers::User1.certificate_email,
+ X509Helpers::User1.signed_commit_time
+ )
+
+ context 'if email is assigned to a user' do
+ let!(:user) { create(:user, email: X509Helpers::User1.certificate_email) }
+
+ it 'returns user' do
+ expect(signature.user).to eq(user)
+ end
+ end
+
+ it 'if email is not assigned to a user, return nil' do
+ expect(signature.user).to be_nil
+ end
+ end
+
+ context 'tag signature' do
+ let(:certificate_attributes) do
+ {
+ subject_key_identifier: X509Helpers::User1.tag_certificate_subject_key_identifier,
+ subject: X509Helpers::User1.certificate_subject,
+ email: X509Helpers::User1.certificate_email,
+ serial_number: X509Helpers::User1.tag_certificate_serial
+ }
+ end
+
+ let(:issuer_attributes) do
+ {
+ subject_key_identifier: X509Helpers::User1.tag_issuer_subject_key_identifier,
+ subject: X509Helpers::User1.tag_certificate_issuer,
+ crl_url: X509Helpers::User1.tag_certificate_crl
+ }
+ end
+
+ context 'verified signature' do
+ context 'with trusted certificate store' do
+ before do
+ store = OpenSSL::X509::Store.new
+ certificate = OpenSSL::X509::Certificate.new X509Helpers::User1.trust_cert
+ store.add_cert(certificate)
+ allow(OpenSSL::X509::Store).to receive(:new).and_return(store)
+ end
+
+ it 'returns a verified signature if email does match' do
+ signature = described_class.new(
+ X509Helpers::User1.signed_tag_signature,
+ X509Helpers::User1.signed_tag_base_data,
+ X509Helpers::User1.certificate_email,
+ X509Helpers::User1.signed_commit_time
+ )
+
+ expect(signature.x509_certificate).to have_attributes(certificate_attributes)
+ expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes)
+ expect(signature.verified_signature).to be_truthy
+ expect(signature.verification_status).to eq(:verified)
+ end
+
+ it 'returns an unverified signature if email does not match' do
+ signature = described_class.new(
+ X509Helpers::User1.signed_tag_signature,
+ X509Helpers::User1.signed_tag_base_data,
+ "gitlab@example.com",
+ X509Helpers::User1.signed_commit_time
+ )
+
+ expect(signature.x509_certificate).to have_attributes(certificate_attributes)
+ expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes)
+ expect(signature.verified_signature).to be_truthy
+ expect(signature.verification_status).to eq(:unverified)
+ end
+
+ it 'returns an unverified signature if email does match and time is wrong' do
+ signature = described_class.new(
+ X509Helpers::User1.signed_tag_signature,
+ X509Helpers::User1.signed_tag_base_data,
+ X509Helpers::User1.certificate_email,
+ Time.new(2020, 2, 22)
+ )
+
+ expect(signature.x509_certificate).to have_attributes(certificate_attributes)
+ expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes)
+ expect(signature.verified_signature).to be_falsey
+ expect(signature.verification_status).to eq(:unverified)
+ end
+
+ it 'returns an unverified signature if certificate is revoked' do
+ signature = described_class.new(
+ X509Helpers::User1.signed_tag_signature,
+ X509Helpers::User1.signed_tag_base_data,
+ X509Helpers::User1.certificate_email,
+ X509Helpers::User1.signed_commit_time
+ )
+
+ expect(signature.verification_status).to eq(:verified)
+
+ signature.x509_certificate.revoked!
+
+ expect(signature.verification_status).to eq(:unverified)
+ end
+ end
+
+ context 'without trusted certificate within store' do
+ before do
+ store = OpenSSL::X509::Store.new
+ allow(OpenSSL::X509::Store).to receive(:new)
+ .and_return(
+ store
+ )
+ end
+
+ it 'returns an unverified signature' do
+ signature = described_class.new(
+ X509Helpers::User1.signed_tag_signature,
+ X509Helpers::User1.signed_tag_base_data,
+ X509Helpers::User1.certificate_email,
+ X509Helpers::User1.signed_commit_time
+ )
+
+ expect(signature.x509_certificate).to have_attributes(certificate_attributes)
+ expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes)
+ expect(signature.verified_signature).to be_falsey
+ expect(signature.verification_status).to eq(:unverified)
+ end
+ end
+ end
+
+ context 'invalid signature' do
+ it 'returns nil' do
+ signature = described_class.new(
+ X509Helpers::User1.signed_tag_signature.tr('A', 'B'),
+ X509Helpers::User1.signed_tag_base_data,
+ X509Helpers::User1.certificate_email,
+ X509Helpers::User1.signed_commit_time
+ )
+ expect(signature.x509_certificate).to be_nil
+ expect(signature.verified_signature).to be_falsey
+ expect(signature.verification_status).to eq(:unverified)
+ end
+ end
+
+ context 'invalid message' do
+ it 'returns nil' do
+ signature = described_class.new(
+ X509Helpers::User1.signed_tag_signature,
+ 'x',
+ X509Helpers::User1.certificate_email,
+ X509Helpers::User1.signed_commit_time
+ )
+ expect(signature.x509_certificate).to be_nil
+ expect(signature.verified_signature).to be_falsey
+ expect(signature.verification_status).to eq(:unverified)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/x509/tag_spec.rb b/spec/lib/gitlab/x509/tag_spec.rb
new file mode 100644
index 00000000000..4bc9723bd0d
--- /dev/null
+++ b/spec/lib/gitlab/x509/tag_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe Gitlab::X509::Tag do
+ subject(:signature) { described_class.new(tag).signature }
+
+ describe '#signature' do
+ let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
+ let(:project) { create(:project, :repository) }
+
+ describe 'signed tag' do
+ let(:tag) { project.repository.find_tag('v1.1.1') }
+ let(:certificate_attributes) do
+ {
+ subject_key_identifier: X509Helpers::User1.tag_certificate_subject_key_identifier,
+ subject: X509Helpers::User1.certificate_subject,
+ email: X509Helpers::User1.certificate_email,
+ serial_number: X509Helpers::User1.tag_certificate_serial
+ }
+ end
+
+ let(:issuer_attributes) do
+ {
+ subject_key_identifier: X509Helpers::User1.tag_issuer_subject_key_identifier,
+ subject: X509Helpers::User1.tag_certificate_issuer,
+ crl_url: X509Helpers::User1.tag_certificate_crl
+ }
+ end
+
+ it { expect(signature).not_to be_nil }
+ it { expect(signature.verification_status).to eq(:unverified) }
+ it { expect(signature.x509_certificate).to have_attributes(certificate_attributes) }
+ it { expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) }
+ end
+
+ context 'unsigned tag' do
+ let(:tag) { project.repository.find_tag('v1.0.0') }
+
+ it { expect(signature).to be_nil }
+ end
+ end
+end
diff --git a/spec/lib/gitlab_danger_spec.rb b/spec/lib/gitlab_danger_spec.rb
index f4620e54979..8115fbca5e0 100644
--- a/spec/lib/gitlab_danger_spec.rb
+++ b/spec/lib/gitlab_danger_spec.rb
@@ -9,7 +9,7 @@ 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')
+ 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')
end
end
diff --git a/spec/lib/google_api/auth_spec.rb b/spec/lib/google_api/auth_spec.rb
index 719e98c5fdf..fa4e6288681 100644
--- a/spec/lib/google_api/auth_spec.rb
+++ b/spec/lib/google_api/auth_spec.rb
@@ -40,5 +40,19 @@ describe GoogleApi::Auth do
expect(token).to eq('token')
expect(expires_at).to eq('expires_at')
end
+
+ it 'expects the client to receive default options' do
+ config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2')
+
+ expect(OAuth2::Client).to receive(:new).with(
+ config.app_id,
+ config.app_secret,
+ hash_including(
+ **config.args.client_options.deep_symbolize_keys
+ )
+ ).and_call_original
+
+ client.get_token('xxx')
+ end
end
end
diff --git a/spec/lib/grafana/validator_spec.rb b/spec/lib/grafana/validator_spec.rb
index 603e27fd0c0..a048a1f3470 100644
--- a/spec/lib/grafana/validator_spec.rb
+++ b/spec/lib/grafana/validator_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
describe Grafana::Validator do
- let(:grafana_dashboard) { JSON.parse(fixture_file('grafana/simplified_dashboard_response.json'), symbolize_names: true) }
- let(:datasource) { JSON.parse(fixture_file('grafana/datasource_response.json'), symbolize_names: true) }
+ let(:grafana_dashboard) { Gitlab::Json.parse(fixture_file('grafana/simplified_dashboard_response.json'), symbolize_names: true) }
+ let(:datasource) { Gitlab::Json.parse(fixture_file('grafana/datasource_response.json'), symbolize_names: true) }
let(:panel) { grafana_dashboard[:dashboard][:panels].first }
let(:query_params) do
diff --git a/spec/lib/omni_auth/strategies/jwt_spec.rb b/spec/lib/omni_auth/strategies/jwt_spec.rb
index f2b682850e3..302329cf198 100644
--- a/spec/lib/omni_auth/strategies/jwt_spec.rb
+++ b/spec/lib/omni_auth/strategies/jwt_spec.rb
@@ -35,7 +35,7 @@ describe OmniAuth::Strategies::Jwt do
end
end
- ECDSA_NAMED_CURVES = {
+ ecdsa_named_curves = {
'ES256' => 'prime256v1',
'ES384' => 'secp384r1',
'ES512' => 'secp521r1'
@@ -54,7 +54,7 @@ describe OmniAuth::Strategies::Jwt do
private_key_class.generate(2048)
.to_pem
elsif private_key_class == OpenSSL::PKey::EC
- private_key_class.new(ECDSA_NAMED_CURVES[algorithm])
+ private_key_class.new(ecdsa_named_curves[algorithm])
.tap { |key| key.generate_key! }
.to_pem
else
diff --git a/spec/lib/quality/helm_client_spec.rb b/spec/lib/quality/helm_client_spec.rb
deleted file mode 100644
index 8d199fe3531..00000000000
--- a/spec/lib/quality/helm_client_spec.rb
+++ /dev/null
@@ -1,132 +0,0 @@
-# frozen_string_literal: true
-
-require 'fast_spec_helper'
-
-RSpec.describe Quality::HelmClient do
- let(:tiller_namespace) { 'review-apps-ee' }
- let(:namespace) { tiller_namespace }
- let(:release_name) { 'my-release' }
- let(:raw_helm_list_page1) do
- <<~OUTPUT
- {"Next":"review-6709-group-t40qbv",
- "Releases":[
- {"Name":"review-qa-60-reor-1mugd1", "Revision":1,"Updated":"Thu Oct 4 17:52:31 2018","Status":"FAILED", "Chart":"gitlab-1.1.3","AppVersion":"master","Namespace":"#{namespace}"},
- {"Name":"review-7846-fix-s-261vd6","Revision":1,"Updated":"Thu Oct 4 17:33:29 2018","Status":"FAILED","Chart":"gitlab-1.1.3","AppVersion":"master","Namespace":"#{namespace}"},
- {"Name":"review-7867-snowp-lzo3iy","Revision":1,"Updated":"Thu Oct 4 17:22:14 2018","Status":"DEPLOYED","Chart":"gitlab-1.1.3","AppVersion":"master","Namespace":"#{namespace}"},
- {"Name":"review-rename-geo-o4a780","Revision":1,"Updated":"Thu Oct 4 17:14:57 2018","Status":"DEPLOYED","Chart":"gitlab-1.1.3","AppVersion":"master","Namespace":"#{namespace}"},
- {"Name":"review-5781-opera-0k93fx","Revision":1,"Updated":"Thu Oct 4 17:06:15 2018","Status":"FAILED","Chart":"gitlab-1.1.3","AppVersion":"master","Namespace":"#{namespace}"},
- {"Name":"review-6709-group-2pzeec","Revision":1,"Updated":"Thu Oct 4 16:36:59 2018","Status":"FAILED","Chart":"gitlab-1.1.3","AppVersion":"master","Namespace":"#{namespace}"},
- {"Name":"review-ce-to-ee-2-l554mn","Revision":1,"Updated":"Thu Oct 4 16:27:02 2018","Status":"FAILED","Chart":"gitlab-1.1.3","AppVersion":"master","Namespace":"#{namespace}"},
- {"Name":"review-epics-e2e-m690eb","Revision":1,"Updated":"Thu Oct 4 16:08:26 2018","Status":"DEPLOYED","Chart":"gitlab-1.1.3","AppVersion":"master","Namespace":"#{namespace}"},
- {"Name":"review-7126-admin-06fae2","Revision":1,"Updated":"Thu Oct 4 15:56:35 2018","Status":"FAILED","Chart":"gitlab-1.1.3","AppVersion":"master","Namespace":"#{namespace}"},
- {"Name":"review-6983-promo-xyou11","Revision":1,"Updated":"Thu Oct 4 15:15:34 2018","Status":"FAILED","Chart":"gitlab-1.1.3","AppVersion":"master","Namespace":"#{namespace}"}
- ]}
- OUTPUT
- end
- let(:raw_helm_list_page2) do
- <<~OUTPUT
- {"Releases":[
- {"Name":"review-6709-group-t40qbv","Revision":1,"Updated":"Thu Oct 4 17:52:31 2018","Status":"FAILED","Chart":"gitlab-1.1.3","AppVersion":"master","Namespace":"#{namespace}"}
- ]}
- OUTPUT
- end
-
- subject { described_class.new(tiller_namespace: tiller_namespace, namespace: namespace) }
-
- describe '#releases' do
- it 'raises an error if the Helm command fails' do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with([%(helm list --namespace "#{namespace}" --tiller-namespace "#{tiller_namespace}" --output json)])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: false)))
-
- expect { subject.releases.to_a }.to raise_error(described_class::CommandFailedError)
- end
-
- it 'calls helm list with default arguments' do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with([%(helm list --namespace "#{namespace}" --tiller-namespace "#{tiller_namespace}" --output json)])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true)))
-
- subject.releases.to_a
- end
-
- it 'calls helm list with extra arguments' do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with([%(helm list --namespace "#{namespace}" --tiller-namespace "#{tiller_namespace}" --output json --deployed)])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true)))
-
- subject.releases(args: ['--deployed']).to_a
- end
-
- it 'returns a list of Release objects' do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with([%(helm list --namespace "#{namespace}" --tiller-namespace "#{tiller_namespace}" --output json --deployed)])
- .and_return(Gitlab::Popen::Result.new([], raw_helm_list_page2, '', double(success?: true)))
-
- releases = subject.releases(args: ['--deployed']).to_a
-
- expect(releases.size).to eq(1)
- expect(releases[0]).to have_attributes(
- name: 'review-6709-group-t40qbv',
- revision: 1,
- last_update: Time.parse('Thu Oct 4 17:52:31 2018'),
- status: 'FAILED',
- chart: 'gitlab-1.1.3',
- app_version: 'master',
- namespace: namespace
- )
- end
-
- it 'automatically paginates releases' do
- expect(Gitlab::Popen).to receive(:popen_with_detail).ordered
- .with([%(helm list --namespace "#{namespace}" --tiller-namespace "#{tiller_namespace}" --output json)])
- .and_return(Gitlab::Popen::Result.new([], raw_helm_list_page1, '', double(success?: true)))
- expect(Gitlab::Popen).to receive(:popen_with_detail).ordered
- .with([%(helm list --namespace "#{namespace}" --tiller-namespace "#{tiller_namespace}" --output json --offset review-6709-group-t40qbv)])
- .and_return(Gitlab::Popen::Result.new([], raw_helm_list_page2, '', double(success?: true)))
-
- releases = subject.releases.to_a
-
- expect(releases.size).to eq(11)
- expect(releases.last.name).to eq('review-6709-group-t40qbv')
- end
- end
-
- describe '#delete' do
- it 'raises an error if the Helm command fails' do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with([%(helm delete --tiller-namespace "#{tiller_namespace}" --purge #{release_name})])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: false)))
-
- expect { subject.delete(release_name: release_name) }.to raise_error(described_class::CommandFailedError)
- end
-
- it 'calls helm delete with default arguments' do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with([%(helm delete --tiller-namespace "#{tiller_namespace}" --purge #{release_name})])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true)))
-
- expect(subject.delete(release_name: release_name)).to eq('')
- end
-
- context 'with multiple release names' do
- let(:release_name) { %w[my-release my-release-2] }
-
- it 'raises an error if the Helm command fails' do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with([%(helm delete --tiller-namespace "#{tiller_namespace}" --purge #{release_name.join(' ')})])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: false)))
-
- expect { subject.delete(release_name: release_name) }.to raise_error(described_class::CommandFailedError)
- end
-
- it 'calls helm delete with multiple release names' do
- expect(Gitlab::Popen).to receive(:popen_with_detail)
- .with([%(helm delete --tiller-namespace "#{tiller_namespace}" --purge #{release_name.join(' ')})])
- .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true)))
-
- expect(subject.delete(release_name: release_name)).to eq('')
- end
- end
- end
-end
diff --git a/spec/lib/quality/test_level_spec.rb b/spec/lib/quality/test_level_spec.rb
index 6042ab24787..b784a92fa85 100644
--- a/spec/lib/quality/test_level_spec.rb
+++ b/spec/lib/quality/test_level_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe Quality::TestLevel do
context 'when level is unit' do
it 'returns a pattern' do
expect(subject.pattern(:unit))
- .to eq("spec/{bin,config,db,dependencies,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,support_specs,tasks,uploaders,validators,views,workers,elastic_integration}{,/**/}*_spec.rb")
+ .to eq("spec/{bin,channels,config,db,dependencies,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,support_specs,tasks,uploaders,validators,views,workers,elastic_integration}{,/**/}*_spec.rb")
end
end
@@ -89,7 +89,7 @@ RSpec.describe Quality::TestLevel do
context 'when level is unit' do
it 'returns a regexp' do
expect(subject.regexp(:unit))
- .to eq(%r{spec/(bin|config|db|dependencies|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|support_specs|tasks|uploaders|validators|views|workers|elastic_integration)})
+ .to eq(%r{spec/(bin|channels|config|db|dependencies|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|support_specs|tasks|uploaders|validators|views|workers|elastic_integration)})
end
end
diff --git a/spec/lib/rspec_flaky/flaky_example_spec.rb b/spec/lib/rspec_flaky/flaky_example_spec.rb
index 47c88e053e1..d4a1d6c882a 100644
--- a/spec/lib/rspec_flaky/flaky_example_spec.rb
+++ b/spec/lib/rspec_flaky/flaky_example_spec.rb
@@ -77,7 +77,7 @@ describe RspecFlaky::FlakyExample, :aggregate_failures do
it 'updates the first_flaky_at' do
now = Time.now
- expected_first_flaky_at = flaky_example.first_flaky_at ? flaky_example.first_flaky_at : now
+ expected_first_flaky_at = flaky_example.first_flaky_at || now
Timecop.freeze(now) { flaky_example.update_flakiness! }
expect(flaky_example.first_flaky_at).to eq(expected_first_flaky_at)
diff --git a/spec/lib/rspec_flaky/report_spec.rb b/spec/lib/rspec_flaky/report_spec.rb
index 1f0eff83db0..37330f39e1c 100644
--- a/spec/lib/rspec_flaky/report_spec.rb
+++ b/spec/lib/rspec_flaky/report_spec.rb
@@ -31,7 +31,7 @@ describe RspecFlaky::Report, :aggregate_failures do
describe '.load' do
let!(:report_file) do
Tempfile.new(%w[rspec_flaky_report .json]).tap do |f|
- f.write(JSON.pretty_generate(suite_flaky_example_report))
+ f.write(Gitlab::Json.pretty_generate(suite_flaky_example_report))
f.rewind
end
end
@@ -48,7 +48,7 @@ describe RspecFlaky::Report, :aggregate_failures do
describe '.load_json' do
let(:report_json) do
- JSON.pretty_generate(suite_flaky_example_report)
+ Gitlab::Json.pretty_generate(suite_flaky_example_report)
end
it 'loads the report file' do
@@ -103,7 +103,7 @@ describe RspecFlaky::Report, :aggregate_failures do
expect(File.exist?(report_file_path)).to be(true)
expect(File.read(report_file_path))
- .to eq(JSON.pretty_generate(report.flaky_examples.to_h))
+ .to eq(Gitlab::Json.pretty_generate(report.flaky_examples.to_h))
end
end
end
diff --git a/spec/lib/sentry/client/event_spec.rb b/spec/lib/sentry/client/event_spec.rb
index c8604d72ada..58891895bfa 100644
--- a/spec/lib/sentry/client/event_spec.rb
+++ b/spec/lib/sentry/client/event_spec.rb
@@ -18,7 +18,7 @@ describe Sentry::Client do
describe '#issue_latest_event' do
let(:sample_response) do
Gitlab::Utils.deep_indifferent_access(
- JSON.parse(fixture_file('sentry/issue_latest_event_sample_response.json'))
+ Gitlab::Json.parse(fixture_file('sentry/issue_latest_event_sample_response.json'))
)
end
let(:issue_id) { '1234' }
diff --git a/spec/lib/sentry/client/issue_link_spec.rb b/spec/lib/sentry/client/issue_link_spec.rb
index 3434e93365e..293937f6100 100644
--- a/spec/lib/sentry/client/issue_link_spec.rb
+++ b/spec/lib/sentry/client/issue_link_spec.rb
@@ -16,7 +16,7 @@ describe Sentry::Client::IssueLink do
let(:sentry_issue_link_url) { "https://sentrytest.gitlab.com/api/0/groups/#{sentry_issue_id}/integrations/#{integration_id}/" }
let(:integration_id) { 44444 }
- let(:issue_link_sample_response) { JSON.parse(fixture_file('sentry/global_integration_link_sample_response.json')) }
+ let(:issue_link_sample_response) { Gitlab::Json.parse(fixture_file('sentry/global_integration_link_sample_response.json')) }
let(:sentry_api_response) { issue_link_sample_response }
let!(:sentry_api_request) { stub_sentry_request(sentry_issue_link_url, :put, body: sentry_api_response, status: 201) }
@@ -42,7 +42,7 @@ describe Sentry::Client::IssueLink do
let(:sentry_issue_link_url) { "https://sentrytest.gitlab.com/api/0/issues/#{sentry_issue_id}/plugins/gitlab/link/" }
let(:integration_id) { nil }
- let(:issue_link_sample_response) { JSON.parse(fixture_file('sentry/plugin_link_sample_response.json')) }
+ let(:issue_link_sample_response) { Gitlab::Json.parse(fixture_file('sentry/plugin_link_sample_response.json')) }
let!(:sentry_api_request) { stub_sentry_request(sentry_issue_link_url, :post, body: sentry_api_response) }
it_behaves_like 'calls sentry api'
diff --git a/spec/lib/sentry/client/issue_spec.rb b/spec/lib/sentry/client/issue_spec.rb
index 0f57d38d290..b683ad6d4a9 100644
--- a/spec/lib/sentry/client/issue_spec.rb
+++ b/spec/lib/sentry/client/issue_spec.rb
@@ -23,7 +23,7 @@ describe Sentry::Client::Issue do
let(:issues_sample_response) do
Gitlab::Utils.deep_indifferent_access(
- JSON.parse(fixture_file('sentry/issues_sample_response.json'))
+ Gitlab::Json.parse(fixture_file('sentry/issues_sample_response.json'))
)
end
@@ -201,7 +201,7 @@ describe Sentry::Client::Issue do
describe '#issue_details' do
let(:issue_sample_response) do
Gitlab::Utils.deep_indifferent_access(
- JSON.parse(fixture_file('sentry/issue_sample_response.json'))
+ Gitlab::Json.parse(fixture_file('sentry/issue_sample_response.json'))
)
end
diff --git a/spec/lib/sentry/client/projects_spec.rb b/spec/lib/sentry/client/projects_spec.rb
index 6183d4c5816..1b5bbb8f81a 100644
--- a/spec/lib/sentry/client/projects_spec.rb
+++ b/spec/lib/sentry/client/projects_spec.rb
@@ -10,7 +10,7 @@ describe Sentry::Client::Projects do
let(:client) { Sentry::Client.new(sentry_url, token) }
let(:projects_sample_response) do
Gitlab::Utils.deep_indifferent_access(
- JSON.parse(fixture_file('sentry/list_projects_sample_response.json'))
+ Gitlab::Json.parse(fixture_file('sentry/list_projects_sample_response.json'))
)
end
diff --git a/spec/lib/sentry/client/repo_spec.rb b/spec/lib/sentry/client/repo_spec.rb
index 7bc2811ef03..524dca8dcf6 100644
--- a/spec/lib/sentry/client/repo_spec.rb
+++ b/spec/lib/sentry/client/repo_spec.rb
@@ -8,7 +8,7 @@ describe Sentry::Client::Repo do
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
let(:token) { 'test-token' }
let(:client) { Sentry::Client.new(sentry_url, token) }
- let(:repos_sample_response) { JSON.parse(fixture_file('sentry/repos_sample_response.json')) }
+ let(:repos_sample_response) { Gitlab::Json.parse(fixture_file('sentry/repos_sample_response.json')) }
describe '#repos' do
let(:organization_slug) { 'gitlab' }
diff --git a/spec/lib/serializers/json_spec.rb b/spec/lib/serializers/json_spec.rb
index a8d82d70e89..dfe85d3f362 100644
--- a/spec/lib/serializers/json_spec.rb
+++ b/spec/lib/serializers/json_spec.rb
@@ -15,7 +15,7 @@ describe Serializers::JSON do
describe '.load' do
let(:data_string) { '{"key":"value","variables":[{"key":"VAR1","value":"VALUE1"}]}' }
- let(:data_hash) { JSON.parse(data_string) }
+ let(:data_hash) { Gitlab::Json.parse(data_string) }
context 'when loading a hash' do
subject { described_class.load(data_hash) }
diff --git a/spec/lib/system_check/app/hashed_storage_all_projects_check_spec.rb b/spec/lib/system_check/app/hashed_storage_all_projects_check_spec.rb
new file mode 100644
index 00000000000..e5e7f6a4450
--- /dev/null
+++ b/spec/lib/system_check/app/hashed_storage_all_projects_check_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'rake_helper'
+
+describe SystemCheck::App::HashedStorageAllProjectsCheck do
+ before do
+ silence_output
+ end
+
+ describe '#check?' do
+ it 'fails when at least one project is in legacy storage' do
+ create(:project, :legacy_storage)
+
+ expect(subject.check?).to be_falsey
+ end
+
+ it 'succeeds when all projects are in hashed storage' do
+ create(:project)
+
+ expect(subject.check?).to be_truthy
+ end
+ end
+end
diff --git a/spec/lib/system_check/app/hashed_storage_enabled_check_spec.rb b/spec/lib/system_check/app/hashed_storage_enabled_check_spec.rb
new file mode 100644
index 00000000000..d5a0014b791
--- /dev/null
+++ b/spec/lib/system_check/app/hashed_storage_enabled_check_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'rake_helper'
+
+describe SystemCheck::App::HashedStorageEnabledCheck do
+ before do
+ silence_output
+ end
+
+ describe '#check?' do
+ it 'fails when hashed storage is disabled' do
+ stub_application_setting(hashed_storage_enabled: false)
+
+ expect(subject.check?).to be_falsey
+ end
+
+ it 'succeeds when hashed storage is enabled' do
+ stub_application_setting(hashed_storage_enabled: true)
+
+ expect(subject.check?).to be_truthy
+ end
+ end
+end
diff --git a/spec/lib/system_check/simple_executor_spec.rb b/spec/lib/system_check/simple_executor_spec.rb
index 94094343ec6..58f3a7df197 100644
--- a/spec/lib/system_check/simple_executor_spec.rb
+++ b/spec/lib/system_check/simple_executor_spec.rb
@@ -4,99 +4,109 @@ require 'spec_helper'
require 'rake_helper'
describe SystemCheck::SimpleExecutor do
- class SimpleCheck < SystemCheck::BaseCheck
- set_name 'my simple check'
-
- def check?
- true
+ before do
+ stub_const('SimpleCheck', Class.new(SystemCheck::BaseCheck))
+ stub_const('OtherCheck', Class.new(SystemCheck::BaseCheck))
+ stub_const('SkipCheck', Class.new(SystemCheck::BaseCheck))
+ stub_const('DynamicSkipCheck', Class.new(SystemCheck::BaseCheck))
+ stub_const('MultiCheck', Class.new(SystemCheck::BaseCheck))
+ stub_const('SkipMultiCheck', Class.new(SystemCheck::BaseCheck))
+ stub_const('RepairCheck', Class.new(SystemCheck::BaseCheck))
+ stub_const('BugousCheck', Class.new(SystemCheck::BaseCheck))
+
+ SimpleCheck.class_eval do
+ set_name 'my simple check'
+
+ def check?
+ true
+ end
end
- end
- class OtherCheck < SystemCheck::BaseCheck
- set_name 'other check'
+ OtherCheck.class_eval do
+ set_name 'other check'
- def check?
- false
- end
+ def check?
+ false
+ end
- def show_error
- $stdout.puts 'this is an error text'
+ def show_error
+ $stdout.puts 'this is an error text'
+ end
end
- end
- class SkipCheck < SystemCheck::BaseCheck
- set_name 'skip check'
- set_skip_reason 'this is a skip reason'
+ SkipCheck.class_eval do
+ set_name 'skip check'
+ set_skip_reason 'this is a skip reason'
- def skip?
- true
- end
+ def skip?
+ true
+ end
- def check?
- raise 'should not execute this'
+ def check?
+ raise 'should not execute this'
+ end
end
- end
- class DynamicSkipCheck < SystemCheck::BaseCheck
- set_name 'dynamic skip check'
- set_skip_reason 'this is a skip reason'
+ DynamicSkipCheck.class_eval do
+ set_name 'dynamic skip check'
+ set_skip_reason 'this is a skip reason'
- def skip?
- self.skip_reason = 'this is a dynamic skip reason'
- true
- end
+ def skip?
+ self.skip_reason = 'this is a dynamic skip reason'
+ true
+ end
- def check?
- raise 'should not execute this'
+ def check?
+ raise 'should not execute this'
+ end
end
- end
- class MultiCheck < SystemCheck::BaseCheck
- set_name 'multi check'
+ MultiCheck.class_eval do
+ set_name 'multi check'
- def multi_check
- $stdout.puts 'this is a multi output check'
- end
+ def multi_check
+ $stdout.puts 'this is a multi output check'
+ end
- def check?
- raise 'should not execute this'
+ def check?
+ raise 'should not execute this'
+ end
end
- end
- class SkipMultiCheck < SystemCheck::BaseCheck
- set_name 'skip multi check'
+ SkipMultiCheck.class_eval do
+ set_name 'skip multi check'
- def skip?
- true
- end
+ def skip?
+ true
+ end
- def multi_check
- raise 'should not execute this'
+ def multi_check
+ raise 'should not execute this'
+ end
end
- end
- class RepairCheck < SystemCheck::BaseCheck
- set_name 'repair check'
+ RepairCheck.class_eval do
+ set_name 'repair check'
- def check?
- false
- end
+ def check?
+ false
+ end
- def repair!
- true
- end
+ def repair!
+ true
+ end
- def show_error
- $stdout.puts 'this is an error message'
+ def show_error
+ $stdout.puts 'this is an error message'
+ end
end
- end
- class BugousCheck < SystemCheck::BaseCheck
- CustomError = Class.new(StandardError)
- set_name 'my bugous check'
+ BugousCheck.class_eval do
+ set_name 'my bugous check'
- def check?
- raise CustomError, 'omg'
+ def check?
+ raise StandardError, 'omg'
+ end
end
end
diff --git a/spec/lib/system_check_spec.rb b/spec/lib/system_check_spec.rb
index f3ed6ca31c9..da1916455ba 100644
--- a/spec/lib/system_check_spec.rb
+++ b/spec/lib/system_check_spec.rb
@@ -4,19 +4,22 @@ require 'spec_helper'
require 'rake_helper'
describe SystemCheck do
- class SimpleCheck < SystemCheck::BaseCheck
- def check?
- true
+ before do
+ stub_const('SimpleCheck', Class.new(SystemCheck::BaseCheck))
+ stub_const('OtherCheck', Class.new(SystemCheck::BaseCheck))
+
+ SimpleCheck.class_eval do
+ def check?
+ true
+ end
end
- end
- class OtherCheck < SystemCheck::BaseCheck
- def check?
- false
+ OtherCheck.class_eval do
+ def check?
+ false
+ end
end
- end
- before do
silence_output
end
diff --git a/spec/mailers/emails/groups_spec.rb b/spec/mailers/emails/groups_spec.rb
new file mode 100644
index 00000000000..b4746e120e0
--- /dev/null
+++ b/spec/mailers/emails/groups_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'email_spec'
+
+describe Emails::Groups do
+ include EmailSpec::Matchers
+
+ let(:group) { create(:group) }
+ let(:user) { create(:user) }
+
+ before do
+ group.add_owner(user)
+ end
+
+ describe '#group_was_exported_email' do
+ subject { Notify.group_was_exported_email(user, group) }
+
+ it 'sends success email' do
+ expect(subject).to have_subject "#{group.name} | Group was exported"
+ expect(subject).to have_body_text 'The download link will expire in 24 hours.'
+ expect(subject).to have_body_text "groups/#{group.path}/-/download_export"
+ end
+ end
+
+ describe '#group_was_not_exported_email' do
+ let(:shared) { Gitlab::ImportExport::Shared.new(group) }
+ let(:error) { Gitlab::ImportExport::Error.new('Error!') }
+
+ before do
+ shared.error(error)
+ end
+
+ subject { Notify.group_was_not_exported_email(user, group, shared.errors) }
+
+ it 'sends failure email' do
+ expect(subject).to have_subject "#{group.name} | Group export error"
+ expect(subject).to have_body_text "Group #{group.name} couldn't be exported."
+ end
+ end
+end
diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb
index 58c04fb4834..f84bf43b9c4 100644
--- a/spec/mailers/emails/profile_spec.rb
+++ b/spec/mailers/emails/profile_spec.rb
@@ -156,4 +156,44 @@ describe Emails::Profile do
it { expect { Notify.access_token_about_to_expire_email('foo') }.not_to raise_error }
end
end
+
+ describe 'user unknown sign in email' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:ip) { '169.0.0.1' }
+
+ subject { Notify.unknown_sign_in_email(user, ip) }
+
+ 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
+ expect(subject).to deliver_to user.email
+ end
+
+ it 'has the correct subject' do
+ expect(subject).to have_subject /^Unknown sign-in from new location$/
+ end
+
+ it 'mentions the unknown sign-in IP' do
+ expect(subject).to have_body_text /A sign-in to your account has been made from the following IP address: #{ip}./
+ end
+
+ it 'includes a link to the change password page' do
+ expect(subject).to have_body_text /#{edit_profile_password_path}/
+ end
+
+ it 'mentions two factor authentication when two factor is not enabled' do
+ expect(subject).to have_body_text /two-factor authentication/
+ end
+
+ context 'when two factor authentication is enabled' do
+ it 'does not mention two factor authentication' do
+ two_factor_user = create(:user, :two_factor)
+
+ expect( Notify.unknown_sign_in_email(two_factor_user, ip) )
+ .not_to have_body_text /two-factor authentication/
+ end
+ end
+ end
end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index d21efe2e1fe..3c66902bb2e 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -70,6 +70,7 @@ describe Notify do
it_behaves_like 'an email starting a new thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -116,6 +117,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -155,6 +157,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'a user cannot unsubscribe through footer link'
it_behaves_like 'an email with a labels subscriptions link in its footer'
@@ -200,6 +203,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -214,6 +218,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -248,6 +253,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
@@ -300,6 +306,7 @@ describe Notify do
it_behaves_like 'an email starting a new thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -344,6 +351,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like "an unsubscribeable thread"
it_behaves_like 'appearance header and footer enabled'
@@ -409,6 +417,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'a user cannot unsubscribe through footer link'
it_behaves_like 'an email with a labels subscriptions link in its footer'
@@ -436,6 +445,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -466,6 +476,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -502,6 +513,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -533,6 +545,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -694,6 +707,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { project_snippet }
end
+
it_behaves_like 'a user cannot unsubscribe through footer link'
it 'has the correct subject' do
@@ -712,6 +726,29 @@ describe Notify do
end
end
+ describe 'for design notes' do
+ let_it_be(:design) { create(:design, :with_file) }
+ let_it_be(:recipient) { create(:user) }
+ let_it_be(:note) do
+ create(:diff_note_on_design,
+ noteable: design,
+ note: "Hello #{recipient.to_reference}")
+ end
+
+ let(:header_name) { 'X-Gitlab-DesignManagement-Design-ID' }
+ let(:refer_to_design) do
+ have_attributes(subject: a_string_including(design.filename))
+ end
+
+ subject { described_class.note_design_email(recipient.id, note.id) }
+
+ it { is_expected.to have_header(header_name, design.id.to_s) }
+
+ it { is_expected.to have_body_text(design.filename) }
+
+ it { is_expected.to refer_to_design }
+ end
+
describe 'project was moved' do
let(:recipient) { user }
@@ -913,6 +950,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { commit }
end
+
it_behaves_like 'it should show Gmail Actions View Commit link'
it_behaves_like 'a user cannot unsubscribe through footer link'
it_behaves_like 'appearance header and footer enabled'
@@ -939,6 +977,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -965,6 +1004,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -1037,6 +1077,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { commit }
end
+
it_behaves_like 'it should show Gmail Actions View Commit link'
it_behaves_like 'a user cannot unsubscribe through footer link'
it_behaves_like 'appearance header and footer enabled'
@@ -1069,6 +1110,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
+
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
@@ -1101,6 +1143,7 @@ describe Notify do
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { issue }
end
+
it_behaves_like 'it should show Gmail Actions View Issue link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
diff --git a/spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb b/spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb
new file mode 100644
index 00000000000..f9e8a7ee6e9
--- /dev/null
+++ b/spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200511145545_change_variable_interpolation_format_in_common_metrics')
+
+describe ChangeVariableInterpolationFormatInCommonMetrics, :migration do
+ let(:prometheus_metrics) { table(:prometheus_metrics) }
+
+ let!(:common_metric) do
+ prometheus_metrics.create!(
+ identifier: 'system_metrics_kubernetes_container_memory_total',
+ query: 'avg(sum(container_memory_usage_bytes{container_name!="POD",' \
+ 'pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"})' \
+ ' by (job)) without (job) /1024/1024/1024',
+ project_id: nil,
+ title: 'Memory Usage (Total)',
+ y_label: 'Total Memory Used (GB)',
+ unit: 'GB',
+ legend: 'Total (GB)',
+ group: -5,
+ common: true
+ )
+ end
+
+ it 'updates query to use {{}}' do
+ expected_query = 'avg(sum(container_memory_usage_bytes{container_name!="POD",' \
+ 'pod_name=~"^{{ci_environment_slug}}-(.*)",namespace="{{kube_namespace}}"})' \
+ ' by (job)) without (job) /1024/1024/1024'
+
+ migrate!
+
+ expect(common_metric.reload.query).to eq(expected_query)
+ end
+end
diff --git a/spec/migrations/backfill_snippet_repositories_spec.rb b/spec/migrations/backfill_snippet_repositories_spec.rb
new file mode 100644
index 00000000000..e87bf7376dd
--- /dev/null
+++ b/spec/migrations/backfill_snippet_repositories_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200420094444_backfill_snippet_repositories.rb')
+
+describe BackfillSnippetRepositories do
+ let(:users) { table(:users) }
+ let(:snippets) { table(:snippets) }
+ let(:user) { users.create(id: 1, email: 'user@example.com', projects_limit: 10, username: 'test', name: 'Test', state: 'active') }
+
+ def create_snippet(id)
+ params = {
+ id: id,
+ type: 'PersonalSnippet',
+ author_id: user.id,
+ file_name: 'foo',
+ content: 'bar'
+ }
+
+ snippets.create!(params)
+ end
+
+ it 'correctly schedules background migrations' do
+ create_snippet(1)
+ create_snippet(2)
+ create_snippet(3)
+
+ stub_const("#{described_class.name}::BATCH_SIZE", 2)
+
+ Sidekiq::Testing.fake! do
+ Timecop.freeze do
+ migrate!
+
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(3.minutes, 1, 2)
+
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(6.minutes, 3, 3)
+
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/cleanup_optimistic_locking_nulls_pt2_fixed_spec.rb b/spec/migrations/cleanup_optimistic_locking_nulls_pt2_fixed_spec.rb
new file mode 100644
index 00000000000..2e5e450afc7
--- /dev/null
+++ b/spec/migrations/cleanup_optimistic_locking_nulls_pt2_fixed_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200427064130_cleanup_optimistic_locking_nulls_pt2_fixed.rb')
+
+describe CleanupOptimisticLockingNullsPt2Fixed, :migration do
+ test_tables = %w(ci_stages ci_builds ci_pipelines).freeze
+ test_tables.each do |table|
+ let(table.to_sym) { table(table.to_sym) }
+ end
+ let(:tables) { test_tables.map { |t| method(t.to_sym).call } }
+
+ before do
+ # Create necessary rows
+ ci_stages.create!
+ ci_builds.create!
+ ci_pipelines.create!
+
+ # Nullify `lock_version` column for all rows
+ # Needs to be done with a SQL fragment, otherwise Rails will coerce it to 0
+ tables.each do |table|
+ table.update_all('lock_version = NULL')
+ end
+ end
+
+ it 'correctly migrates nullified lock_version column', :sidekiq_might_not_need_inline do
+ tables.each do |table|
+ expect(table.where(lock_version: nil).count).to eq(1)
+ end
+
+ tables.each do |table|
+ expect(table.where(lock_version: 0).count).to eq(0)
+ end
+
+ migrate!
+
+ tables.each do |table|
+ expect(table.where(lock_version: nil).count).to eq(0)
+ end
+
+ tables.each do |table|
+ expect(table.where(lock_version: 0).count).to eq(1)
+ end
+ end
+end
diff --git a/spec/migrations/cleanup_optimistic_locking_nulls_spec.rb b/spec/migrations/cleanup_optimistic_locking_nulls_spec.rb
index d32a374b914..6e541c903ff 100644
--- a/spec/migrations/cleanup_optimistic_locking_nulls_spec.rb
+++ b/spec/migrations/cleanup_optimistic_locking_nulls_spec.rb
@@ -4,11 +4,10 @@ require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20200128210353_cleanup_optimistic_locking_nulls')
describe CleanupOptimisticLockingNulls do
- TABLES = %w(epics merge_requests issues).freeze
- TABLES.each do |table|
- let(table.to_sym) { table(table.to_sym) }
- end
- let(:tables) { TABLES.map { |t| method(t.to_sym).call } }
+ let(:epics) { table(:epics) }
+ let(:merge_requests) { table(:merge_requests) }
+ let(:issues) { table(:issues) }
+ let(:tables) { [epics, merge_requests, issues] }
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
diff --git a/spec/migrations/cleanup_projects_with_missing_namespace_spec.rb b/spec/migrations/cleanup_projects_with_missing_namespace_spec.rb
new file mode 100644
index 00000000000..06b6d5e3b46
--- /dev/null
+++ b/spec/migrations/cleanup_projects_with_missing_namespace_spec.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require Rails.root.join('db', 'post_migrate', '20200511080113_add_projects_foreign_key_to_namespaces.rb')
+require Rails.root.join('db', 'post_migrate', '20200511083541_cleanup_projects_with_missing_namespace.rb')
+
+LOST_AND_FOUND_GROUP = 'lost-and-found'
+USER_TYPE_GHOST = 5
+ACCESS_LEVEL_OWNER = 50
+
+# In order to test the CleanupProjectsWithMissingNamespace migration, we need
+# to first create an orphaned project (one with an invalid namespace_id)
+# and then run the migration to check that the project was properly cleaned up
+#
+# The problem is that the CleanupProjectsWithMissingNamespace migration comes
+# after the FK has been added with a previous migration (AddProjectsForeignKeyToNamespaces)
+# That means that while testing the current class we can not insert projects with an
+# invalid namespace_id as the existing FK is correctly blocking us from doing so
+#
+# The approach that solves that problem is to:
+# - Set the schema of this test to the one prior to AddProjectsForeignKeyToNamespaces
+# - We could hardcode it to `20200508091106` (which currently is the previous
+# migration before adding the FK) but that would mean that this test depends
+# on migration 20200508091106 not being reverted or deleted
+# - So, we use SchemaVersionFinder that finds the previous migration and returns
+# its schema, which we then use in the describe
+#
+# That means that we lock the schema version to the one returned by
+# SchemaVersionFinder.previous_migration and only test the cleanup migration
+# *without* the migration that adds the Foreign Key ever running
+# That's acceptable as the cleanup script should not be affected in any way
+# by the migration that adds the Foreign Key
+class SchemaVersionFinder
+ def self.migrations_paths
+ ActiveRecord::Migrator.migrations_paths
+ end
+
+ def self.migration_context
+ ActiveRecord::MigrationContext.new(migrations_paths, ActiveRecord::SchemaMigration)
+ end
+
+ def self.migrations
+ migration_context.migrations
+ end
+
+ def self.previous_migration
+ migrations.each_cons(2) do |previous, migration|
+ break previous.version if migration.name == AddProjectsForeignKeyToNamespaces.name
+ end
+ end
+end
+
+describe CleanupProjectsWithMissingNamespace, :migration, schema: SchemaVersionFinder.previous_migration do
+ let(:projects) { table(:projects) }
+ let(:namespaces) { table(:namespaces) }
+ let(:users) { table(:users) }
+
+ before do
+ namespace = namespaces.create!(name: 'existing_namespace', path: 'existing_namespace')
+
+ projects.create!(
+ name: 'project_with_existing_namespace',
+ path: 'project_with_existing_namespace',
+ visibility_level: 20,
+ archived: false,
+ namespace_id: namespace.id
+ )
+
+ projects.create!(
+ name: 'project_with_non_existing_namespace',
+ path: 'project_with_non_existing_namespace',
+ visibility_level: 20,
+ archived: false,
+ namespace_id: non_existing_record_id
+ )
+ end
+
+ it 'creates the ghost user' do
+ expect(users.where(user_type: USER_TYPE_GHOST).count).to eq(0)
+
+ disable_migrations_output { migrate! }
+
+ expect(users.where(user_type: USER_TYPE_GHOST).count).to eq(1)
+ end
+
+ it 'creates the lost-and-found group, owned by the ghost user' do
+ expect(
+ Group.where(Group.arel_table[:name].matches("#{LOST_AND_FOUND_GROUP}%")).count
+ ).to eq(0)
+
+ disable_migrations_output { migrate! }
+
+ ghost_user = users.find_by(user_type: USER_TYPE_GHOST)
+ expect(
+ Group
+ .joins('INNER JOIN members ON namespaces.id = members.source_id')
+ .where('namespaces.type = ?', 'Group')
+ .where('members.type = ?', 'GroupMember')
+ .where('members.source_type = ?', 'Namespace')
+ .where('members.user_id = ?', ghost_user.id)
+ .where('members.requested_at IS NULL')
+ .where('members.access_level = ?', ACCESS_LEVEL_OWNER)
+ .where(Group.arel_table[:name].matches("#{LOST_AND_FOUND_GROUP}%"))
+ .count
+ ).to eq(1)
+ end
+
+ it 'moves the orphaned project to the lost-and-found group' do
+ orphaned_project = projects.find_by(name: 'project_with_non_existing_namespace')
+ expect(orphaned_project.visibility_level).to eq(20)
+ expect(orphaned_project.archived).to eq(false)
+ expect(orphaned_project.namespace_id).to eq(non_existing_record_id)
+
+ disable_migrations_output { migrate! }
+
+ lost_and_found_group = Group.find_by(Group.arel_table[:name].matches("#{LOST_AND_FOUND_GROUP}%"))
+ orphaned_project = projects.find_by(id: orphaned_project.id)
+
+ expect(orphaned_project.visibility_level).to eq(0)
+ expect(orphaned_project.namespace_id).to eq(lost_and_found_group.id)
+ expect(orphaned_project.name).to eq("project_with_non_existing_namespace_#{orphaned_project.id}")
+ expect(orphaned_project.path).to eq("project_with_non_existing_namespace_#{orphaned_project.id}")
+ expect(orphaned_project.archived).to eq(true)
+
+ valid_project = projects.find_by(name: 'project_with_existing_namespace')
+ existing_namespace = namespaces.find_by(name: 'existing_namespace')
+
+ expect(valid_project.visibility_level).to eq(20)
+ expect(valid_project.namespace_id).to eq(existing_namespace.id)
+ expect(valid_project.path).to eq('project_with_existing_namespace')
+ expect(valid_project.archived).to eq(false)
+ end
+end
diff --git a/spec/migrations/encrypt_plaintext_attributes_on_application_settings_spec.rb b/spec/migrations/encrypt_plaintext_attributes_on_application_settings_spec.rb
index 87a72ed0cf5..fda810d1da9 100644
--- a/spec/migrations/encrypt_plaintext_attributes_on_application_settings_spec.rb
+++ b/spec/migrations/encrypt_plaintext_attributes_on_application_settings_spec.rb
@@ -8,7 +8,7 @@ describe EncryptPlaintextAttributesOnApplicationSettings do
let(:application_settings) { table(:application_settings) }
let(:plaintext) { 'secret-token' }
- PLAINTEXT_ATTRIBUTES = %w[
+ plaintext_attributes = %w[
akismet_api_key
elasticsearch_aws_secret_access_key
recaptcha_private_key
@@ -21,7 +21,7 @@ describe EncryptPlaintextAttributesOnApplicationSettings do
it 'encrypts token and saves it' do
application_setting = application_settings.create
application_setting.update_columns(
- PLAINTEXT_ATTRIBUTES.each_with_object({}) do |plaintext_attribute, attributes|
+ plaintext_attributes.each_with_object({}) do |plaintext_attribute, attributes|
attributes[plaintext_attribute] = plaintext
end
)
@@ -29,7 +29,7 @@ describe EncryptPlaintextAttributesOnApplicationSettings do
migration.up
application_setting.reload
- PLAINTEXT_ATTRIBUTES.each do |plaintext_attribute|
+ plaintext_attributes.each do |plaintext_attribute|
expect(application_setting[plaintext_attribute]).not_to be_nil
expect(application_setting["encrypted_#{plaintext_attribute}"]).not_to be_nil
expect(application_setting["encrypted_#{plaintext_attribute}_iv"]).not_to be_nil
@@ -40,7 +40,7 @@ describe EncryptPlaintextAttributesOnApplicationSettings do
describe '#down' do
it 'decrypts encrypted token and saves it' do
application_setting = application_settings.create(
- PLAINTEXT_ATTRIBUTES.each_with_object({}) do |plaintext_attribute, attributes|
+ plaintext_attributes.each_with_object({}) do |plaintext_attribute, attributes|
attributes[plaintext_attribute] = plaintext
end
)
@@ -48,7 +48,7 @@ describe EncryptPlaintextAttributesOnApplicationSettings do
migration.down
application_setting.reload
- PLAINTEXT_ATTRIBUTES.each do |plaintext_attribute|
+ plaintext_attributes.each do |plaintext_attribute|
expect(application_setting[plaintext_attribute]).to eq(plaintext)
expect(application_setting["encrypted_#{plaintext_attribute}"]).to be_nil
expect(application_setting["encrypted_#{plaintext_attribute}_iv"]).to be_nil
diff --git a/spec/migrations/fill_file_store_ci_job_artifacts_spec.rb b/spec/migrations/fill_file_store_ci_job_artifacts_spec.rb
new file mode 100644
index 00000000000..5435a438824
--- /dev/null
+++ b/spec/migrations/fill_file_store_ci_job_artifacts_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20200513235532_fill_file_store_ci_job_artifacts.rb')
+
+describe FillFileStoreCiJobArtifacts do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:builds) { table(:ci_builds) }
+ let(:job_artifacts) { table(:ci_job_artifacts) }
+
+ before do
+ namespaces.create!(id: 123, name: 'sample', path: 'sample')
+ projects.create!(id: 123, name: 'sample', path: 'sample', namespace_id: 123)
+ builds.create!(id: 1)
+ end
+
+ context 'when file_store is nil' do
+ it 'updates file_store to local' do
+ job_artifacts.create!(project_id: 123, job_id: 1, file_type: 1, file_store: nil)
+ job_artifact = job_artifacts.find_by(project_id: 123, job_id: 1)
+
+ expect { migrate! }.to change { job_artifact.reload.file_store }.from(nil).to(1)
+ end
+ end
+
+ context 'when file_store is set to local' do
+ it 'does not update file_store' do
+ job_artifacts.create!(project_id: 123, job_id: 1, file_type: 1, file_store: 1)
+ job_artifact = job_artifacts.find_by(project_id: 123, job_id: 1)
+
+ expect { migrate! }.not_to change { job_artifact.reload.file_store }
+ end
+ end
+
+ context 'when file_store is set to object storage' do
+ it 'does not update file_store' do
+ job_artifacts.create!(project_id: 123, job_id: 1, file_type: 1, file_store: 2)
+ job_artifact = job_artifacts.find_by(project_id: 123, job_id: 1)
+
+ expect { migrate! }.not_to change { job_artifact.reload.file_store }
+ end
+ end
+end
diff --git a/spec/migrations/fill_file_store_lfs_objects_spec.rb b/spec/migrations/fill_file_store_lfs_objects_spec.rb
new file mode 100644
index 00000000000..e574eacca35
--- /dev/null
+++ b/spec/migrations/fill_file_store_lfs_objects_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20200513234502_fill_file_store_lfs_objects.rb')
+
+describe FillFileStoreLfsObjects do
+ let(:lfs_objects) { table(:lfs_objects) }
+ let(:oid) { 'b804383982bb89b00e828e3f44c038cc991d3d1768009fc39ba8e2c081b9fb75' }
+
+ context 'when file_store is nil' do
+ it 'updates file_store to local' do
+ lfs_objects.create(oid: oid, size: 1062, file_store: nil)
+ lfs_object = lfs_objects.find_by(oid: oid)
+
+ expect { migrate! }.to change { lfs_object.reload.file_store }.from(nil).to(1)
+ end
+ end
+
+ context 'when file_store is set to local' do
+ it 'does not update file_store' do
+ lfs_objects.create(oid: oid, size: 1062, file_store: 1)
+ lfs_object = lfs_objects.find_by(oid: oid)
+
+ expect { migrate! }.not_to change { lfs_object.reload.file_store }
+ end
+ end
+
+ context 'when file_store is set to object storage' do
+ it 'does not update file_store' do
+ lfs_objects.create(oid: oid, size: 1062, file_store: 2)
+ lfs_object = lfs_objects.find_by(oid: oid)
+
+ expect { migrate! }.not_to change { lfs_object.reload.file_store }
+ end
+ end
+end
diff --git a/spec/migrations/fill_store_uploads_spec.rb b/spec/migrations/fill_store_uploads_spec.rb
new file mode 100644
index 00000000000..6a2a3c4ea8e
--- /dev/null
+++ b/spec/migrations/fill_store_uploads_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20200513235347_fill_store_uploads.rb')
+
+describe FillStoreUploads do
+ let(:uploads) { table(:uploads) }
+ let(:path) { 'uploads/-/system/avatar.jpg' }
+
+ context 'when store is nil' do
+ it 'updates store to local' do
+ uploads.create(size: 100.kilobytes,
+ uploader: 'AvatarUploader',
+ path: path,
+ store: nil)
+
+ upload = uploads.find_by(path: path)
+
+ expect { migrate! }.to change { upload.reload.store }.from(nil).to(1)
+ end
+ end
+
+ context 'when store is set to local' do
+ it 'does not update store' do
+ uploads.create(size: 100.kilobytes,
+ uploader: 'AvatarUploader',
+ path: path,
+ store: 1)
+
+ upload = uploads.find_by(path: path)
+
+ expect { migrate! }.not_to change { upload.reload.store }
+ end
+ end
+
+ context 'when store is set to object storage' do
+ it 'does not update store' do
+ uploads.create(size: 100.kilobytes,
+ uploader: 'AvatarUploader',
+ path: path,
+ store: 2)
+
+ upload = uploads.find_by(path: path)
+
+ expect { migrate! }.not_to change { upload.reload.store }
+ end
+ end
+end
diff --git a/spec/migrations/remove_additional_application_settings_rows_spec.rb b/spec/migrations/remove_additional_application_settings_rows_spec.rb
new file mode 100644
index 00000000000..379fa385b8e
--- /dev/null
+++ b/spec/migrations/remove_additional_application_settings_rows_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require Rails.root.join('db', 'post_migrate', '20200420162730_remove_additional_application_settings_rows.rb')
+
+describe RemoveAdditionalApplicationSettingsRows do
+ let(:application_settings) { table(:application_settings) }
+
+ it 'removes additional rows from application settings' do
+ 3.times { application_settings.create! }
+ latest_settings = application_settings.create!
+
+ disable_migrations_output { migrate! }
+
+ expect(application_settings.count).to eq(1)
+ expect(application_settings.first).to eq(latest_settings)
+ end
+
+ it 'leaves only row in application_settings' do
+ latest_settings = application_settings.create!
+
+ disable_migrations_output { migrate! }
+
+ expect(application_settings.first).to eq(latest_settings)
+ end
+end
diff --git a/spec/migrations/remove_deprecated_jenkins_service_records_spec.rb b/spec/migrations/remove_deprecated_jenkins_service_records_spec.rb
new file mode 100644
index 00000000000..9c9abd36203
--- /dev/null
+++ b/spec/migrations/remove_deprecated_jenkins_service_records_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20200511130129_remove_deprecated_jenkins_service_records.rb')
+require Rails.root.join('db', 'post_migrate', '20200511130130_ensure_deprecated_jenkins_service_records_removal.rb')
+
+shared_examples 'remove DeprecatedJenkinsService records' do
+ let(:services) { table(:services) }
+
+ before do
+ services.create!(type: 'JenkinsDeprecatedService')
+ services.create!(type: 'JenkinsService')
+ end
+
+ it 'deletes services when template and attached to a project' do
+ expect { migrate! }
+ .to change { services.where(type: 'JenkinsDeprecatedService').count }.from(1).to(0)
+ .and not_change { services.where(type: 'JenkinsService').count }
+ end
+end
+
+describe RemoveDeprecatedJenkinsServiceRecords, :migration do
+ it_behaves_like 'remove DeprecatedJenkinsService records'
+end
+
+describe EnsureDeprecatedJenkinsServiceRecordsRemoval, :migration do
+ it_behaves_like 'remove DeprecatedJenkinsService records'
+end
diff --git a/spec/migrations/remove_orphaned_invited_members_spec.rb b/spec/migrations/remove_orphaned_invited_members_spec.rb
new file mode 100644
index 00000000000..0ed4c15428a
--- /dev/null
+++ b/spec/migrations/remove_orphaned_invited_members_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20200424050250_remove_orphaned_invited_members.rb')
+
+describe RemoveOrphanedInvitedMembers do
+ let(:members_table) { table(:members) }
+ let(:users_table) { table(:users) }
+ let(:namespaces_table) { table(:namespaces) }
+ let(:projects_table) { table(:projects) }
+
+ let!(:user1) { users_table.create!(name: 'user1', email: 'user1@example.com', projects_limit: 1) }
+ let!(:user2) { users_table.create!(name: 'user2', email: 'user2@example.com', projects_limit: 1) }
+ let!(:group) { namespaces_table.create!(type: 'Group', name: 'group', path: 'group') }
+ let!(:project) { projects_table.create!(name: 'project', path: 'project', namespace_id: group.id) }
+
+ let!(:member1) { create_member(user_id: user1.id, source_type: 'Project', source_id: project.id, access_level: 10) }
+ let!(:member2) { create_member(user_id: user2.id, source_type: 'Group', source_id: group.id, access_level: 20) }
+
+ let!(:invited_member1) do
+ create_member(user_id: nil, source_type: 'Project', source_id: project.id,
+ invite_token: SecureRandom.hex, invite_accepted_at: Time.now,
+ access_level: 20)
+ end
+ let!(:invited_member2) do
+ create_member(user_id: nil, source_type: 'Group', source_id: group.id,
+ invite_token: SecureRandom.hex, invite_accepted_at: Time.now,
+ access_level: 20)
+ end
+
+ let!(:orphaned_member1) do
+ create_member(user_id: nil, source_type: 'Project', source_id: project.id,
+ invite_accepted_at: Time.now, access_level: 30)
+ end
+ let!(:orphaned_member2) do
+ create_member(user_id: nil, source_type: 'Group', source_id: group.id,
+ invite_accepted_at: Time.now, access_level: 20)
+ end
+
+ it 'removes orphaned invited members but keeps current members' do
+ expect { migrate! }.to change { members_table.count }.from(6).to(4)
+
+ expect(members_table.all.pluck(:id)).to contain_exactly(member1.id, member2.id, invited_member1.id, invited_member2.id)
+ end
+
+ def create_member(options)
+ members_table.create!(
+ {
+ notification_level: 0,
+ ldap: false,
+ override: false
+ }.merge(options)
+ )
+ end
+end
diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb
index 2bf971f553f..9ef77da6f43 100644
--- a/spec/models/ability_spec.rb
+++ b/spec/models/ability_spec.rb
@@ -74,13 +74,20 @@ describe Ability do
context 'using a private project' do
let(:project) { create(:project, :private) }
- it 'returns users that are administrators' do
+ it 'returns users that are administrators when admin mode is enabled', :enable_admin_mode do
user = build(:user, admin: true)
expect(described_class.users_that_can_read_project([user], project))
.to eq([user])
end
+ it 'does not return users that are administrators when admin mode is disabled' do
+ user = build(:user, admin: true)
+
+ expect(described_class.users_that_can_read_project([user], project))
+ .to eq([])
+ end
+
it 'returns external users if they are the project owner' do
user1 = build(:user, external: true)
user2 = build(:user, external: true)
@@ -145,7 +152,7 @@ describe Ability do
end
describe '.merge_requests_readable_by_user' do
- context 'with an admin' do
+ context 'with an admin when admin mode is enabled', :enable_admin_mode do
it 'returns all merge requests' do
user = build(:user, admin: true)
merge_request = build(:merge_request)
@@ -155,6 +162,19 @@ describe Ability do
end
end
+ context 'with an admin when admin mode is disabled' do
+ it 'returns merge_requests that are publicly visible' do
+ user = build(:user, admin: true)
+ hidden_merge_request = build(:merge_request)
+ visible_merge_request = build(:merge_request, source_project: build(:project, :public))
+
+ merge_requests = described_class
+ .merge_requests_readable_by_user([hidden_merge_request, visible_merge_request], user)
+
+ expect(merge_requests).to eq([visible_merge_request])
+ end
+ end
+
context 'without a user' do
it 'returns merge_requests that are publicly visible' do
hidden_merge_request = build(:merge_request)
@@ -217,7 +237,7 @@ describe Ability do
end
describe '.issues_readable_by_user' do
- context 'with an admin user' do
+ context 'with an admin when admin mode is enabled', :enable_admin_mode do
it 'returns all given issues' do
user = build(:user, admin: true)
issue = build(:issue)
@@ -227,6 +247,26 @@ describe Ability do
end
end
+ context 'with an admin when admin mode is disabled' do
+ it 'returns the issues readable by the admin' do
+ user = build(:user, admin: true)
+ issue = build(:issue)
+
+ expect(issue).to receive(:readable_by?).with(user).and_return(true)
+
+ expect(described_class.issues_readable_by_user([issue], user))
+ .to eq([issue])
+ end
+
+ it 'returns no issues when not given access' do
+ user = build(:user, admin: true)
+ issue = build(:issue)
+
+ expect(described_class.issues_readable_by_user([issue], user))
+ .to be_empty
+ end
+ end
+
context 'with a regular user' do
it 'returns the issues readable by the user' do
user = build(:user)
diff --git a/spec/models/alert_management/alert_spec.rb b/spec/models/alert_management/alert_spec.rb
new file mode 100644
index 00000000000..1da0c6d4071
--- /dev/null
+++ b/spec/models/alert_management/alert_spec.rb
@@ -0,0 +1,320 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe AlertManagement::Alert do
+ describe 'associations' do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:issue) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:title) }
+ it { is_expected.to validate_presence_of(:events) }
+ it { is_expected.to validate_presence_of(:severity) }
+ it { is_expected.to validate_presence_of(:status) }
+ it { is_expected.to validate_presence_of(:started_at) }
+
+ it { is_expected.to validate_length_of(:title).is_at_most(200) }
+ it { is_expected.to validate_length_of(:description).is_at_most(1000) }
+ it { is_expected.to validate_length_of(:service).is_at_most(100) }
+ it { is_expected.to validate_length_of(:monitoring_tool).is_at_most(100) }
+
+ context 'when status is triggered' do
+ context 'when ended_at is blank' do
+ subject { build(:alert_management_alert) }
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'when ended_at is present' do
+ subject { build(:alert_management_alert, ended_at: Time.current) }
+
+ it { is_expected.to be_invalid }
+ end
+ end
+
+ context 'when status is acknowledged' do
+ context 'when ended_at is blank' do
+ subject { build(:alert_management_alert, :acknowledged) }
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'when ended_at is present' do
+ subject { build(:alert_management_alert, :acknowledged, ended_at: Time.current) }
+
+ it { is_expected.to be_invalid }
+ end
+ end
+
+ context 'when status is resolved' do
+ context 'when ended_at is blank' do
+ subject { build(:alert_management_alert, :resolved, ended_at: nil) }
+
+ it { is_expected.to be_invalid }
+ end
+
+ context 'when ended_at is present' do
+ subject { build(:alert_management_alert, :resolved, ended_at: Time.current) }
+
+ it { is_expected.to be_valid }
+ end
+ end
+
+ context 'when status is ignored' do
+ context 'when ended_at is blank' do
+ subject { build(:alert_management_alert, :ignored) }
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'when ended_at is present' do
+ subject { build(:alert_management_alert, :ignored, ended_at: Time.current) }
+
+ it { is_expected.to be_invalid }
+ end
+ end
+
+ describe 'fingerprint' do
+ let_it_be(:fingerprint) { 'fingerprint' }
+ let_it_be(:existing_alert) { create(:alert_management_alert, fingerprint: fingerprint) }
+ let(:new_alert) { build(:alert_management_alert, fingerprint: fingerprint, project: project) }
+
+ subject { new_alert }
+
+ context 'adding an alert with the same fingerprint' do
+ context 'same project' do
+ let(:project) { existing_alert.project }
+
+ it { is_expected.not_to be_valid }
+ end
+
+ context 'different project' do
+ let(:project) { create(:project) }
+
+ it { is_expected.to be_valid }
+ end
+ end
+ end
+
+ describe 'hosts' do
+ subject(:alert) { build(:alert_management_alert, hosts: hosts) }
+
+ context 'over 255 total chars' do
+ let(:hosts) { ['111.111.111.111'] * 18 }
+
+ it { is_expected.not_to be_valid }
+ end
+
+ context 'under 255 chars' do
+ let(:hosts) { ['111.111.111.111'] * 17 }
+
+ it { is_expected.to be_valid }
+ end
+ end
+ end
+
+ describe 'enums' do
+ let(:severity_values) do
+ { critical: 0, high: 1, medium: 2, low: 3, info: 4, unknown: 5 }
+ end
+
+ it { is_expected.to define_enum_for(:severity).with_values(severity_values) }
+ end
+
+ describe 'scopes' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:triggered_alert) { create(:alert_management_alert, project: project) }
+ let_it_be(:resolved_alert) { create(:alert_management_alert, :resolved, project: project) }
+ let_it_be(:ignored_alert) { create(:alert_management_alert, :ignored, project: project) }
+
+ describe '.for_iid' do
+ subject { AlertManagement::Alert.for_iid(triggered_alert.iid) }
+
+ it { is_expected.to match_array(triggered_alert) }
+ end
+
+ describe '.for_status' do
+ let(:status) { AlertManagement::Alert::STATUSES[:resolved] }
+
+ subject { AlertManagement::Alert.for_status(status) }
+
+ it { is_expected.to match_array(resolved_alert) }
+
+ context 'with multiple statuses' do
+ let(:status) { AlertManagement::Alert::STATUSES.values_at(:resolved, :ignored) }
+
+ it { is_expected.to match_array([resolved_alert, ignored_alert]) }
+ end
+ end
+
+ describe '.for_fingerprint' do
+ let_it_be(:fingerprint) { SecureRandom.hex }
+ let_it_be(:alert_with_fingerprint) { create(:alert_management_alert, project: project, fingerprint: fingerprint) }
+ let_it_be(:unrelated_alert_with_finger_print) { create(:alert_management_alert, fingerprint: fingerprint) }
+
+ subject { described_class.for_fingerprint(project, fingerprint) }
+
+ it { is_expected.to contain_exactly(alert_with_fingerprint) }
+ end
+
+ describe '.counts_by_status' do
+ subject { described_class.counts_by_status }
+
+ it do
+ is_expected.to eq(
+ triggered_alert.status => 1,
+ resolved_alert.status => 1,
+ ignored_alert.status => 1
+ )
+ end
+ end
+ end
+
+ describe '.search' do
+ let_it_be(:alert) do
+ create(:alert_management_alert,
+ title: 'Title',
+ description: 'Desc',
+ service: 'Service',
+ monitoring_tool: 'Monitor'
+ )
+ end
+
+ subject { AlertManagement::Alert.search(query) }
+
+ context 'does not contain search string' do
+ let(:query) { 'something else' }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'title includes query' do
+ let(:query) { alert.title.upcase }
+
+ it { is_expected.to contain_exactly(alert) }
+ end
+
+ context 'description includes query' do
+ let(:query) { alert.description.upcase }
+
+ it { is_expected.to contain_exactly(alert) }
+ end
+
+ context 'service includes query' do
+ let(:query) { alert.service.upcase }
+
+ it { is_expected.to contain_exactly(alert) }
+ end
+
+ context 'monitoring tool includes query' do
+ let(:query) { alert.monitoring_tool.upcase }
+
+ it { is_expected.to contain_exactly(alert) }
+ 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, 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'
+ )
+ end
+ end
+
+ describe '#trigger' do
+ subject { alert.trigger }
+
+ context 'when alert is in triggered state' do
+ let(:alert) { create(:alert_management_alert) }
+
+ it 'does not change the alert status' do
+ expect { subject }.not_to change { alert.reload.status }
+ end
+ end
+
+ context 'when alert not in triggered state' do
+ let(:alert) { create(:alert_management_alert, :resolved) }
+
+ it 'changes the alert status to triggered' do
+ expect { subject }.to change { alert.triggered? }.to(true)
+ end
+
+ it 'resets ended at' do
+ expect { subject }.to change { alert.reload.ended_at }.to nil
+ end
+ end
+ end
+
+ describe '#acknowledge' do
+ subject { alert.acknowledge }
+
+ let(:alert) { create(:alert_management_alert, :resolved) }
+
+ it 'changes the alert status to acknowledged' do
+ expect { subject }.to change { alert.acknowledged? }.to(true)
+ end
+
+ it 'resets ended at' do
+ expect { subject }.to change { alert.reload.ended_at }.to nil
+ end
+ end
+
+ describe '#resolve' do
+ let!(:ended_at) { Time.current }
+
+ subject do
+ alert.ended_at = ended_at
+ alert.resolve
+ end
+
+ context 'when alert already resolved' do
+ let(:alert) { create(:alert_management_alert, :resolved) }
+
+ it 'does not change the alert status' do
+ expect { subject }.not_to change { alert.reload.status }
+ end
+ end
+
+ context 'when alert is not resolved' do
+ let(:alert) { create(:alert_management_alert) }
+
+ it 'changes alert status to "resolved"' do
+ expect { subject }.to change { alert.resolved? }.to(true)
+ end
+ end
+ end
+
+ describe '#ignore' do
+ subject { alert.ignore }
+
+ let(:alert) { create(:alert_management_alert, :resolved) }
+
+ it 'changes the alert status to ignored' do
+ expect { subject }.to change { alert.ignored? }.to(true)
+ end
+
+ it 'resets ended at' do
+ expect { subject }.to change { alert.reload.ended_at }.to nil
+ end
+ end
+end
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index 523e17f82c1..64308af38f9 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -91,6 +91,20 @@ describe ApplicationSetting do
it { is_expected.not_to allow_value(nil).for(:namespace_storage_size_limit) }
it { is_expected.not_to allow_value(-1).for(:namespace_storage_size_limit) }
+ it { is_expected.to allow_value(300).for(:issues_create_limit) }
+ it { is_expected.not_to allow_value('three').for(:issues_create_limit) }
+ it { is_expected.not_to allow_value(nil).for(:issues_create_limit) }
+ it { is_expected.not_to allow_value(10.5).for(:issues_create_limit) }
+ it { is_expected.not_to allow_value(-1).for(:issues_create_limit) }
+
+ it { is_expected.to allow_value(0).for(:raw_blob_request_limit) }
+ it { is_expected.not_to allow_value('abc').for(:raw_blob_request_limit) }
+ it { is_expected.not_to allow_value(nil).for(:raw_blob_request_limit) }
+ it { is_expected.not_to allow_value(10.5).for(:raw_blob_request_limit) }
+ it { is_expected.not_to allow_value(-1).for(:raw_blob_request_limit) }
+
+ it { is_expected.not_to allow_value(false).for(:hashed_storage_enabled) }
+
context 'grafana_url validations' do
before do
subject.instance_variable_set(:@parsed_grafana_url, nil)
diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb
index a0193b29bb3..c2d6406c3fb 100644
--- a/spec/models/blob_spec.rb
+++ b/spec/models/blob_spec.rb
@@ -5,12 +5,17 @@ require 'spec_helper'
describe Blob do
include FakeBlobHelpers
- let(:project) { build(:project, lfs_enabled: true) }
+ using RSpec::Parameterized::TableSyntax
+
+ let(:project) { build(:project) }
let(:personal_snippet) { build(:personal_snippet) }
let(:project_snippet) { build(:project_snippet, project: project) }
+ let(:repository) { project.repository }
+ let(:lfs_enabled) { true }
+
before do
- allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
+ allow(repository).to receive(:lfs_enabled?) { lfs_enabled }
end
describe '.decorate' do
@@ -27,7 +32,7 @@ describe Blob do
it 'does not fetch blobs when none are accessed' do
expect(container.repository).not_to receive(:blobs_at)
- described_class.lazy(container, commit_id, 'CHANGELOG')
+ described_class.lazy(container.repository, commit_id, 'CHANGELOG')
end
it 'fetches all blobs for the same repository when one is accessed' do
@@ -36,10 +41,10 @@ describe Blob do
.once.and_call_original
expect(other_container.repository).not_to receive(:blobs_at)
- changelog = described_class.lazy(container, commit_id, 'CHANGELOG')
- contributing = described_class.lazy(same_container, commit_id, 'CONTRIBUTING.md')
+ changelog = described_class.lazy(container.repository, commit_id, 'CHANGELOG')
+ contributing = described_class.lazy(same_container.repository, commit_id, 'CONTRIBUTING.md')
- described_class.lazy(other_container, commit_id, 'CHANGELOG')
+ described_class.lazy(other_container.repository, commit_id, 'CHANGELOG')
# Access property so the values are loaded
changelog.id
@@ -47,14 +52,14 @@ describe Blob do
end
it 'does not include blobs from previous requests in later requests' do
- changelog = described_class.lazy(container, commit_id, 'CHANGELOG')
- contributing = described_class.lazy(same_container, commit_id, 'CONTRIBUTING.md')
+ changelog = described_class.lazy(container.repository, commit_id, 'CHANGELOG')
+ contributing = described_class.lazy(same_container.repository, commit_id, 'CONTRIBUTING.md')
# Access property so the values are loaded
changelog.id
contributing.id
- readme = described_class.lazy(container, commit_id, 'README.md')
+ readme = described_class.lazy(container.repository, commit_id, 'README.md')
expect(container.repository).to receive(:blobs_at)
.with([[commit_id, 'README.md']], blob_size_limit: blob_size_limit).once.and_call_original
@@ -128,399 +133,84 @@ describe Blob do
end
describe '#external_storage_error?' do
- shared_examples 'no error' do
- it do
- expect(blob.external_storage_error?).to be_falsey
- end
- end
-
- shared_examples 'returns error' do
- it do
- expect(blob.external_storage_error?).to be_truthy
- end
- end
+ subject { blob.external_storage_error? }
context 'if the blob is stored in LFS' do
- let(:blob) { fake_blob(path: 'file.pdf', lfs: true, container: container) }
-
- context 'when the project has LFS enabled' do
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'no error'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns error'
- end
+ let(:blob) { fake_blob(path: 'file.pdf', lfs: true) }
- context 'with project snippet' do
- let(:container) { project_snippet }
+ context 'when LFS is enabled' do
+ let(:lfs_enabled) { true }
- it_behaves_like 'no error'
- end
+ it { is_expected.to be_falsy }
end
- context 'when the project does not have LFS enabled' do
- before do
- project.lfs_enabled = false
- end
-
- context 'with project' do
- let(:container) { project }
+ context 'when LFS is not enabled' do
+ let(:lfs_enabled) { false }
- it_behaves_like 'returns error'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
-
- it_behaves_like 'returns error'
- end
+ it { is_expected.to be_truthy }
end
end
context 'if the blob is not stored in LFS' do
- let(:blob) { fake_blob(path: 'file.md', container: container) }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'no error'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'no error'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
+ let(:blob) { fake_blob(path: 'file.md') }
- it_behaves_like 'no error'
- end
+ it { is_expected.to be_falsy }
end
end
describe '#stored_externally?' do
+ subject { blob.stored_externally? }
+
context 'if the blob is stored in LFS' do
let(:blob) { fake_blob(path: 'file.pdf', lfs: true) }
- shared_examples 'returns true' do
- it do
- expect(blob.stored_externally?).to be_truthy
- end
- end
-
- shared_examples 'returns false' do
- it do
- expect(blob.stored_externally?).to be_falsey
- end
- end
-
- context 'when the project has LFS enabled' do
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns true'
- end
+ context 'when LFS is enabled' do
+ let(:lfs_enabled) { true }
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns true'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
-
- it_behaves_like 'returns true'
- end
+ it { is_expected.to be_truthy }
end
- context 'when the project does not have LFS enabled' do
- before do
- project.lfs_enabled = false
- end
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns false'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
+ context 'when LFS is not enabled' do
+ let(:lfs_enabled) { false }
- it_behaves_like 'returns false'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
-
- it_behaves_like 'returns false'
- end
+ it { is_expected.to be_falsy }
end
end
context 'if the blob is not stored in LFS' do
let(:blob) { fake_blob(path: 'file.md') }
- it 'returns false' do
- expect(blob.stored_externally?).to be_falsey
- end
+ it { is_expected.to be_falsy }
end
end
describe '#binary?' do
- shared_examples 'returns true' do
- it do
- expect(blob.binary?).to be_truthy
- end
- end
-
- shared_examples 'returns false' do
- it do
- expect(blob.binary?).to be_falsey
- end
- end
-
- context 'if the blob is stored externally' do
- let(:blob) { fake_blob(path: file, lfs: true) }
-
- context 'if the extension has a rich viewer' do
- context 'if the viewer is binary' do
- let(:file) { 'file.pdf' }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns true'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns true'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
-
- it_behaves_like 'returns true'
- end
- end
-
- context 'if the viewer is text-based' do
- let(:file) { 'file.md' }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns false'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns false'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
-
- it_behaves_like 'returns false'
- end
- end
+ context 'an lfs object' do
+ where(:filename, :is_binary) do
+ 'file.pdf' | true
+ 'file.md' | false
+ 'file.txt' | false
+ 'file.ics' | false
+ 'file.rb' | false
+ 'file.exe' | true
+ 'file.ini' | false
+ 'file.wtf' | true
end
- context "if the extension doesn't have a rich viewer" do
- context 'if the extension has a text mime type' do
- context 'if the extension is for a programming language' do
- let(:file) { 'file.txt' }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns false'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns false'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
-
- it_behaves_like 'returns false'
- end
- end
-
- context 'if the extension is not for a programming language' do
- let(:file) { 'file.ics' }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns false'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns false'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
+ with_them do
+ let(:blob) { fake_blob(path: filename, lfs: true, container: project) }
- it_behaves_like 'returns false'
- end
- end
- end
-
- context 'if the extension has a binary mime type' do
- context 'if the extension is for a programming language' do
- let(:file) { 'file.rb' }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns false'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns false'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
-
- it_behaves_like 'returns false'
- end
- end
-
- context 'if the extension is not for a programming language' do
- let(:file) { 'file.exe' }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns true'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns true'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
-
- it_behaves_like 'returns true'
- end
- end
- end
-
- context 'if the extension has an unknown mime type' do
- context 'if the extension is for a programming language' do
- let(:file) { 'file.ini' }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns false'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns false'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
-
- it_behaves_like 'returns false'
- end
- end
-
- context 'if the extension is not for a programming language' do
- let(:file) { 'file.wtf' }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns true'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns true'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
-
- it_behaves_like 'returns true'
- end
- end
- end
+ it { expect(blob.binary?).to eq(is_binary) }
end
end
- context 'if the blob is not stored externally' do
- context 'if the blob is binary' do
- let(:blob) { fake_blob(path: 'file.pdf', binary: true, container: container) }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns true'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns true'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
-
- it_behaves_like 'returns true'
- end
- end
-
- context 'if the blob is text-based' do
- let(:blob) { fake_blob(path: 'file.md', container: container) }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns false'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns false'
- end
+ context 'a non-lfs object' do
+ let(:blob) { fake_blob(path: 'anything', container: project) }
- context 'with project snippet' do
- let(:container) { project_snippet }
+ it 'delegates to binary_in_repo?' do
+ expect(blob).to receive(:binary_in_repo?) { :result }
- it_behaves_like 'returns false'
- end
+ expect(blob.binary?).to eq(:result)
end
end
end
@@ -569,9 +259,7 @@ describe Blob do
describe '#rich_viewer' do
context 'when the blob has an external storage error' do
- before do
- project.lfs_enabled = false
- end
+ let(:lfs_enabled) { false }
it 'returns nil' do
blob = fake_blob(path: 'file.pdf', lfs: true)
@@ -631,9 +319,7 @@ describe Blob do
describe '#auxiliary_viewer' do
context 'when the blob has an external storage error' do
- before do
- project.lfs_enabled = false
- end
+ let(:lfs_enabled) { false }
it 'returns nil' do
blob = fake_blob(path: 'LICENSE', lfs: true)
@@ -676,63 +362,21 @@ describe Blob do
end
describe '#rendered_as_text?' do
- shared_examples 'returns true' do
- it do
- expect(blob.rendered_as_text?(ignore_errors: ignore_errors)).to be_truthy
- end
- end
-
- shared_examples 'returns false' do
- it do
- expect(blob.rendered_as_text?(ignore_errors: ignore_errors)).to be_falsey
- end
- end
+ subject { blob.rendered_as_text?(ignore_errors: ignore_errors) }
context 'when ignoring errors' do
let(:ignore_errors) { true }
context 'when the simple viewer is text-based' do
- let(:blob) { fake_blob(path: 'file.md', size: 100.megabytes, container: container) }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns true'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
+ let(:blob) { fake_blob(path: 'file.md', size: 100.megabytes) }
- it_behaves_like 'returns true'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
-
- it_behaves_like 'returns true'
- end
+ it { is_expected.to be_truthy }
end
context 'when the simple viewer is binary' do
- let(:blob) { fake_blob(path: 'file.pdf', binary: true, size: 100.megabytes, container: container) }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns false'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns false'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
+ let(:blob) { fake_blob(path: 'file.pdf', binary: true, size: 100.megabytes) }
- it_behaves_like 'returns false'
- end
+ it { is_expected.to be_falsy }
end
end
@@ -740,47 +384,15 @@ describe Blob do
let(:ignore_errors) { false }
context 'when the viewer has render errors' do
- let(:blob) { fake_blob(path: 'file.md', size: 100.megabytes, container: container) }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns false'
- end
-
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns false'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
+ let(:blob) { fake_blob(path: 'file.md', size: 100.megabytes) }
- it_behaves_like 'returns false'
- end
+ it { is_expected.to be_falsy }
end
context "when the viewer doesn't have render errors" do
- let(:blob) { fake_blob(path: 'file.md', container: container) }
-
- context 'with project' do
- let(:container) { project }
-
- it_behaves_like 'returns true'
- end
+ let(:blob) { fake_blob(path: 'file.md') }
- context 'with personal snippet' do
- let(:container) { personal_snippet }
-
- it_behaves_like 'returns true'
- end
-
- context 'with project snippet' do
- let(:container) { project_snippet }
-
- it_behaves_like 'returns true'
- end
+ it { is_expected.to be_truthy }
end
end
end
diff --git a/spec/models/blob_viewer/readme_spec.rb b/spec/models/blob_viewer/readme_spec.rb
index 6586adbc373..89bc5be94fb 100644
--- a/spec/models/blob_viewer/readme_spec.rb
+++ b/spec/models/blob_viewer/readme_spec.rb
@@ -40,7 +40,7 @@ describe BlobViewer::Readme do
context 'when the wiki is not empty' do
before do
- create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: 'Home page' })
+ create(:wiki_page, wiki: project.wiki, title: 'home', content: 'Home page')
end
it 'returns nil' do
diff --git a/spec/models/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb
index 6cef81d6e44..127faa5e8e2 100644
--- a/spec/models/broadcast_message_spec.rb
+++ b/spec/models/broadcast_message_spec.rb
@@ -143,6 +143,24 @@ describe BroadcastMessage do
expect(subject.call('/group/groupname/issues').length).to eq(0)
end
+
+ it 'does not return message if target path has no wild card at the end' do
+ create(:broadcast_message, target_path: "*/issues", broadcast_type: broadcast_type)
+
+ expect(subject.call('/group/issues/test').length).to eq(0)
+ end
+
+ it 'does not return message if target path has wild card at the end' do
+ create(:broadcast_message, target_path: "/issues/*", broadcast_type: broadcast_type)
+
+ expect(subject.call('/group/issues/test').length).to eq(0)
+ end
+
+ it 'does return message if target path has wild card at the beginning and the end' do
+ create(:broadcast_message, target_path: "*/issues/*", broadcast_type: broadcast_type)
+
+ expect(subject.call('/group/issues/test').length).to eq(1)
+ end
end
describe '.current', :use_clean_rails_memory_store_caching do
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index a4f3fa518c6..6605866d9c0 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -106,10 +106,14 @@ describe Ci::Build do
end
end
- describe '.with_artifacts_archive' do
- subject { described_class.with_artifacts_archive }
+ describe '.with_downloadable_artifacts' do
+ subject { described_class.with_downloadable_artifacts }
- context 'when job does not have an archive' do
+ before do
+ stub_feature_flags(drop_license_management_artifact: false)
+ end
+
+ context 'when job does not have a downloadable artifact' do
let!(:job) { create(:ci_build) }
it 'does not return the job' do
@@ -117,15 +121,23 @@ describe Ci::Build do
end
end
- context 'when job has a job artifact archive' do
- let!(:job) { create(:ci_build, :artifacts) }
+ ::Ci::JobArtifact::DOWNLOADABLE_TYPES.each do |type|
+ context "when job has a #{type} artifact" do
+ it 'returns the job' do
+ job = create(:ci_build)
+ create(
+ :ci_job_artifact,
+ file_format: ::Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS[type.to_sym],
+ file_type: type,
+ job: job
+ )
- it 'returns the job' do
- is_expected.to include(job)
+ is_expected.to include(job)
+ end
end
end
- context 'when job has a job artifact trace' do
+ context 'when job has a non-downloadable artifact' do
let!(:job) { create(:ci_build, :trace_artifact) }
it 'does not return the job' do
@@ -1419,6 +1431,8 @@ describe Ci::Build do
subject { build.erase_erasable_artifacts! }
before do
+ stub_feature_flags(drop_license_management_artifact: false)
+
Ci::JobArtifact.file_types.keys.each do |file_type|
create(:ci_job_artifact, job: build, file_type: file_type, file_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS[file_type.to_sym])
end
@@ -2367,12 +2381,14 @@ describe Ci::Build do
let(:pipeline_pre_var) { { key: 'pipeline', value: 'value', public: true, masked: false } }
let(:build_yaml_var) { { key: 'yaml', value: 'value', public: true, masked: false } }
let(:job_jwt_var) { { key: 'CI_JOB_JWT', value: 'ci.job.jwt', public: false, masked: true } }
+ let(:job_dependency_var) { { key: 'job_dependency', value: 'value', public: true, masked: false } }
before do
allow(build).to receive(:predefined_variables) { [build_pre_var] }
allow(build).to receive(:yaml_variables) { [build_yaml_var] }
allow(build).to receive(:persisted_variables) { [] }
allow(build).to receive(:job_jwt_variables) { [job_jwt_var] }
+ allow(build).to receive(:dependency_variables) { [job_dependency_var] }
allow_any_instance_of(Project)
.to receive(:predefined_variables) { [project_pre_var] }
@@ -2390,6 +2406,7 @@ describe Ci::Build do
project_pre_var,
pipeline_pre_var,
build_yaml_var,
+ job_dependency_var,
{ key: 'secret', value: 'value', public: false, masked: false }])
end
end
@@ -2884,6 +2901,19 @@ describe Ci::Build do
it { is_expected.to include(deployment_variable) }
end
+ context 'when build has a freeze period' do
+ let(:freeze_variable) { { key: 'CI_DEPLOY_FREEZE', value: 'true', masked: false, public: true } }
+
+ before do
+ expect_next_instance_of(Ci::FreezePeriodStatus) do |freeze_period|
+ expect(freeze_period).to receive(:execute)
+ .and_return(true)
+ end
+ end
+
+ it { is_expected.to include(freeze_variable) }
+ end
+
context 'when project has default CI config path' do
let(:ci_config_path) { { key: 'CI_CONFIG_PATH', value: '.gitlab-ci.yml', public: true, masked: false } }
@@ -2987,6 +3017,15 @@ describe Ci::Build do
end
end
end
+
+ context 'when build has dependency which has dotenv variable' do
+ let!(:prepare) { create(:ci_build, pipeline: pipeline, stage_idx: 0) }
+ let!(:build) { create(:ci_build, pipeline: pipeline, stage_idx: 1, options: { dependencies: [prepare.name] }) }
+
+ let!(:job_variable) { create(:ci_job_variable, :dotenv_source, job: prepare) }
+
+ it { is_expected.to include(key: job_variable.key, value: job_variable.value, public: false, masked: false) }
+ end
end
describe '#scoped_variables' do
@@ -3049,71 +3088,36 @@ describe Ci::Build do
end
end
end
- end
- describe '#secret_group_variables' do
- subject { build.secret_group_variables }
-
- let!(:variable) { create(:ci_group_variable, protected: true, group: group) }
+ context 'with dependency variables' do
+ let!(:prepare) { create(:ci_build, name: 'prepare', pipeline: pipeline, stage_idx: 0) }
+ let!(:build) { create(:ci_build, pipeline: pipeline, stage_idx: 1, options: { dependencies: ['prepare'] }) }
- context 'when ref is branch' do
- let(:build) { create(:ci_build, ref: 'master', tag: false, project: project) }
+ let!(:job_variable) { create(:ci_job_variable, :dotenv_source, job: prepare) }
- context 'when ref is protected' do
+ context 'FF ci_dependency_variables is enabled' do
before do
- create(:protected_branch, :developers_can_merge, name: 'master', project: project)
+ stub_feature_flags(ci_dependency_variables: true)
end
- it { is_expected.to include(variable) }
- end
-
- context 'when ref is not protected' do
- it { is_expected.not_to include(variable) }
- end
- end
-
- context 'when ref is tag' do
- let(:build) { create(:ci_build, ref: 'v1.1.0', tag: true, project: project) }
-
- context 'when ref is protected' do
- before do
- create(:protected_tag, project: project, name: 'v*')
+ it 'inherits dependent variables' do
+ expect(build.scoped_variables.to_hash).to include(job_variable.key => job_variable.value)
end
-
- it { is_expected.to include(variable) }
- end
-
- context 'when ref is not protected' do
- it { is_expected.not_to include(variable) }
end
- end
- context 'when ref is merge request' do
- let(:merge_request) { create(:merge_request, :with_detached_merge_request_pipeline) }
- let(:pipeline) { merge_request.pipelines_for_merge_request.first }
- let(:build) { create(:ci_build, ref: merge_request.source_branch, tag: false, pipeline: pipeline, project: project) }
-
- context 'when ref is protected' do
+ context 'FF ci_dependency_variables is disabled' do
before do
- create(:protected_branch, :developers_can_merge, name: merge_request.source_branch, project: project)
+ stub_feature_flags(ci_dependency_variables: false)
end
- it 'does not return protected variables as it is not supported for merge request pipelines' do
- is_expected.not_to include(variable)
+ it 'does not inherit dependent variables' do
+ expect(build.scoped_variables.to_hash).not_to include(job_variable.key => job_variable.value)
end
end
-
- context 'when ref is not protected' do
- it { is_expected.not_to include(variable) }
- end
end
end
- describe '#secret_project_variables' do
- subject { build.secret_project_variables }
-
- let!(:variable) { create(:ci_variable, protected: true, project: project) }
-
+ shared_examples "secret CI variables" do
context 'when ref is branch' do
let(:build) { create(:ci_build, ref: 'master', tag: false, project: project) }
@@ -3167,6 +3171,30 @@ describe Ci::Build do
end
end
+ describe '#secret_instance_variables' do
+ subject { build.secret_instance_variables }
+
+ let_it_be(:variable) { create(:ci_instance_variable, protected: true) }
+
+ include_examples "secret CI variables"
+ end
+
+ describe '#secret_group_variables' do
+ subject { build.secret_group_variables }
+
+ let_it_be(:variable) { create(:ci_group_variable, protected: true, group: group) }
+
+ include_examples "secret CI variables"
+ end
+
+ describe '#secret_project_variables' do
+ subject { build.secret_project_variables }
+
+ let_it_be(:variable) { create(:ci_variable, protected: true, project: project) }
+
+ include_examples "secret CI variables"
+ end
+
describe '#deployment_variables' do
let(:build) { create(:ci_build, environment: environment) }
let(:environment) { 'production' }
@@ -3217,6 +3245,29 @@ describe Ci::Build do
expect(build.scoped_variables_hash).not_to include('MY_VAR': 'myvar')
end
end
+
+ context 'when overriding CI instance variables' do
+ before do
+ create(:ci_instance_variable, key: 'MY_VAR', value: 'my value 1')
+ group.variables.create!(key: 'MY_VAR', value: 'my value 2')
+ end
+
+ it 'returns a regular hash created using valid ordering' do
+ expect(build.scoped_variables_hash).to include('MY_VAR': 'my value 2')
+ expect(build.scoped_variables_hash).not_to include('MY_VAR': 'my value 1')
+ end
+ end
+
+ context 'when CI instance variables are disabled' do
+ before do
+ create(:ci_instance_variable, key: 'MY_VAR', value: 'my value 1')
+ stub_feature_flags(ci_instance_level_variables: false)
+ end
+
+ it 'does not include instance level variables' do
+ expect(build.scoped_variables_hash).not_to include('MY_VAR': 'my value 1')
+ end
+ end
end
describe '#any_unmet_prerequisites?' do
@@ -3293,6 +3344,41 @@ describe Ci::Build do
end
end
+ describe '#dependency_variables' do
+ subject { build.dependency_variables }
+
+ context 'when using dependencies' do
+ let!(:prepare1) { create(:ci_build, name: 'prepare1', pipeline: pipeline, stage_idx: 0) }
+ let!(:prepare2) { create(:ci_build, name: 'prepare2', pipeline: pipeline, stage_idx: 0) }
+ let!(:build) { create(:ci_build, pipeline: pipeline, stage_idx: 1, options: { dependencies: ['prepare1'] }) }
+
+ let!(:job_variable_1) { create(:ci_job_variable, :dotenv_source, job: prepare1) }
+ let!(:job_variable_2) { create(:ci_job_variable, job: prepare1) }
+ let!(:job_variable_3) { create(:ci_job_variable, :dotenv_source, job: prepare2) }
+
+ it 'inherits only dependent variables' do
+ expect(subject.to_hash).to eq(job_variable_1.key => job_variable_1.value)
+ end
+ end
+
+ context 'when using needs' do
+ let!(:prepare1) { create(:ci_build, name: 'prepare1', pipeline: pipeline, stage_idx: 0) }
+ let!(:prepare2) { create(:ci_build, name: 'prepare2', pipeline: pipeline, stage_idx: 0) }
+ let!(:prepare3) { create(:ci_build, name: 'prepare3', pipeline: pipeline, stage_idx: 0) }
+ let!(:build) { create(:ci_build, pipeline: pipeline, stage_idx: 1, scheduling_type: 'dag') }
+ let!(:build_needs_prepare1) { create(:ci_build_need, build: build, name: 'prepare1', artifacts: true) }
+ let!(:build_needs_prepare2) { create(:ci_build_need, build: build, name: 'prepare2', artifacts: false) }
+
+ let!(:job_variable_1) { create(:ci_job_variable, :dotenv_source, job: prepare1) }
+ let!(:job_variable_2) { create(:ci_job_variable, :dotenv_source, job: prepare2) }
+ let!(:job_variable_3) { create(:ci_job_variable, :dotenv_source, job: prepare3) }
+
+ it 'inherits only needs with artifacts variables' do
+ expect(subject.to_hash).to eq(job_variable_1.key => job_variable_1.value)
+ end
+ end
+ end
+
describe 'state transition: any => [:preparing]' do
let(:build) { create(:ci_build, :created) }
@@ -3822,8 +3908,68 @@ describe Ci::Build do
create(:ci_job_artifact, :junit_with_corrupted_data, job: build, project: build.project)
end
- it 'raises an error' do
- expect { subject }.to raise_error(Gitlab::Ci::Parsers::Test::Junit::JunitParserError)
+ it 'returns no test data and includes a suite_error message' do
+ expect { subject }.not_to raise_error
+
+ expect(test_reports.get_suite(build.name).total_count).to eq(0)
+ expect(test_reports.get_suite(build.name).success_count).to eq(0)
+ expect(test_reports.get_suite(build.name).failed_count).to eq(0)
+ expect(test_reports.get_suite(build.name).suite_error).to eq('JUnit XML parsing failed: 1:1: FATAL: Document is empty')
+ end
+ end
+ end
+ end
+
+ describe '#collect_accessibility_reports!' do
+ subject { build.collect_accessibility_reports!(accessibility_report) }
+
+ let(:accessibility_report) { Gitlab::Ci::Reports::AccessibilityReports.new }
+
+ it { expect(accessibility_report.urls).to eq({}) }
+
+ context 'when build has an accessibility report' do
+ context 'when there is an accessibility report with errors' do
+ before do
+ create(:ci_job_artifact, :accessibility, job: build, project: build.project)
+ end
+
+ it 'parses blobs and add the results to the accessibility report' do
+ expect { subject }.not_to raise_error
+
+ expect(accessibility_report.urls.keys).to match_array(['https://about.gitlab.com/'])
+ expect(accessibility_report.errors_count).to eq(10)
+ expect(accessibility_report.scans_count).to eq(1)
+ expect(accessibility_report.passes_count).to eq(0)
+ end
+ end
+
+ context 'when there is an accessibility report without errors' do
+ before do
+ create(:ci_job_artifact, :accessibility_without_errors, job: build, project: build.project)
+ end
+
+ it 'parses blobs and add the results to the accessibility report' do
+ expect { subject }.not_to raise_error
+
+ expect(accessibility_report.urls.keys).to match_array(['https://pa11y.org/'])
+ expect(accessibility_report.errors_count).to eq(0)
+ expect(accessibility_report.scans_count).to eq(1)
+ expect(accessibility_report.passes_count).to eq(1)
+ end
+ end
+
+ context 'when there is an accessibility report with an invalid url' do
+ before do
+ create(:ci_job_artifact, :accessibility_with_invalid_url, job: build, project: build.project)
+ end
+
+ it 'parses blobs and add the results to the accessibility report' do
+ expect { subject }.not_to raise_error
+
+ expect(accessibility_report.urls).to be_empty
+ expect(accessibility_report.errors_count).to eq(0)
+ expect(accessibility_report.scans_count).to eq(0)
+ expect(accessibility_report.passes_count).to eq(0)
end
end
end
@@ -3876,6 +4022,48 @@ describe Ci::Build do
end
end
+ describe '#collect_terraform_reports!' do
+ let(:terraform_reports) { Gitlab::Ci::Reports::TerraformReports.new }
+
+ it 'returns an empty hash' do
+ expect(build.collect_terraform_reports!(terraform_reports).plans).to eq({})
+ end
+
+ context 'when build has a terraform report' do
+ context 'when there is a valid tfplan.json' do
+ before do
+ create(:ci_job_artifact, :terraform, job: build, project: build.project)
+ end
+
+ it 'parses blobs and add the results to the terraform report' do
+ expect { build.collect_terraform_reports!(terraform_reports) }.not_to raise_error
+
+ expect(terraform_reports.plans).to match(
+ a_hash_including(
+ 'tfplan.json' => a_hash_including(
+ 'create' => 0,
+ 'update' => 1,
+ 'delete' => 0
+ )
+ )
+ )
+ end
+ end
+
+ context 'when there is an invalid tfplan.json' do
+ before do
+ create(:ci_job_artifact, :terraform_with_corrupted_data, job: build, project: build.project)
+ end
+
+ it 'raises an error' do
+ expect { build.collect_terraform_reports!(terraform_reports) }.to raise_error(
+ Gitlab::Ci::Parsers::Terraform::Tfplan::TfplanParserError
+ )
+ end
+ end
+ end
+ end
+
describe '#report_artifacts' do
subject { build.report_artifacts }
@@ -3986,6 +4174,28 @@ describe Ci::Build do
it { is_expected.to include(:upload_multiple_artifacts) }
end
+
+ context 'when artifacts exclude is defined and the is feature enabled' do
+ let(:options) do
+ { artifacts: { exclude: %w[something] } }
+ end
+
+ context 'when a feature flag is enabled' do
+ before do
+ stub_feature_flags(ci_artifacts_exclude: true)
+ end
+
+ it { is_expected.to include(:artifacts_exclude) }
+ end
+
+ context 'when a feature flag is disabled' do
+ before do
+ stub_feature_flags(ci_artifacts_exclude: false)
+ end
+
+ it { is_expected.not_to include(:artifacts_exclude) }
+ end
+ end
end
describe '#supported_runner?' do
@@ -4312,4 +4522,31 @@ describe Ci::Build do
it { is_expected.to be_nil }
end
end
+
+ describe '#degradation_threshold' do
+ subject { build.degradation_threshold }
+
+ context 'when threshold variable is defined' do
+ before do
+ build.yaml_variables = [
+ { key: 'SOME_VAR_1', value: 'SOME_VAL_1' },
+ { key: 'DEGRADATION_THRESHOLD', value: '5' },
+ { key: 'SOME_VAR_2', value: 'SOME_VAL_2' }
+ ]
+ end
+
+ it { is_expected.to eq(5) }
+ end
+
+ context 'when threshold variable is not defined' do
+ before do
+ build.yaml_variables = [
+ { key: 'SOME_VAR_1', value: 'SOME_VAL_1' },
+ { key: 'SOME_VAR_2', value: 'SOME_VAL_2' }
+ ]
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
end
diff --git a/spec/models/ci/daily_build_group_report_result_spec.rb b/spec/models/ci/daily_build_group_report_result_spec.rb
new file mode 100644
index 00000000000..d4c305c649a
--- /dev/null
+++ b/spec/models/ci/daily_build_group_report_result_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::DailyBuildGroupReportResult do
+ describe '.upsert_reports' do
+ let!(:rspec_coverage) do
+ create(
+ :ci_daily_build_group_report_result,
+ group_name: 'rspec',
+ date: '2020-03-09',
+ data: { coverage: 71.2 }
+ )
+ end
+ let!(:new_pipeline) { create(:ci_pipeline) }
+
+ it 'creates or updates matching report results' do
+ described_class.upsert_reports([
+ {
+ project_id: rspec_coverage.project_id,
+ ref_path: rspec_coverage.ref_path,
+ last_pipeline_id: new_pipeline.id,
+ date: rspec_coverage.date,
+ group_name: 'rspec',
+ data: { 'coverage' => 81.0 }
+ },
+ {
+ project_id: rspec_coverage.project_id,
+ ref_path: rspec_coverage.ref_path,
+ last_pipeline_id: new_pipeline.id,
+ date: rspec_coverage.date,
+ group_name: 'karma',
+ data: { 'coverage' => 87.0 }
+ }
+ ])
+
+ rspec_coverage.reload
+
+ expect(rspec_coverage).to have_attributes(
+ last_pipeline_id: new_pipeline.id,
+ data: { 'coverage' => 81.0 }
+ )
+
+ expect(described_class.find_by_group_name('karma')).to have_attributes(
+ project_id: rspec_coverage.project_id,
+ ref_path: rspec_coverage.ref_path,
+ last_pipeline_id: new_pipeline.id,
+ date: rspec_coverage.date,
+ data: { 'coverage' => 87.0 }
+ )
+ end
+
+ context 'when given data is empty' do
+ it 'does nothing' do
+ expect { described_class.upsert_reports([]) }.not_to raise_error
+ end
+ end
+ end
+end
diff --git a/spec/models/ci/daily_report_result_spec.rb b/spec/models/ci/daily_report_result_spec.rb
deleted file mode 100644
index 61aa58c6692..00000000000
--- a/spec/models/ci/daily_report_result_spec.rb
+++ /dev/null
@@ -1,62 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe Ci::DailyReportResult do
- describe '.upsert_reports' do
- let!(:rspec_coverage) do
- create(
- :ci_daily_report_result,
- title: 'rspec',
- date: '2020-03-09',
- value: 71.2
- )
- end
- let!(:new_pipeline) { create(:ci_pipeline) }
-
- it 'creates or updates matching report results' do
- described_class.upsert_reports([
- {
- project_id: rspec_coverage.project_id,
- ref_path: rspec_coverage.ref_path,
- param_type: described_class.param_types[rspec_coverage.param_type],
- last_pipeline_id: new_pipeline.id,
- date: rspec_coverage.date,
- title: 'rspec',
- value: 81.0
- },
- {
- project_id: rspec_coverage.project_id,
- ref_path: rspec_coverage.ref_path,
- param_type: described_class.param_types[rspec_coverage.param_type],
- last_pipeline_id: new_pipeline.id,
- date: rspec_coverage.date,
- title: 'karma',
- value: 87.0
- }
- ])
-
- rspec_coverage.reload
-
- expect(rspec_coverage).to have_attributes(
- last_pipeline_id: new_pipeline.id,
- value: 81.0
- )
-
- expect(described_class.find_by_title('karma')).to have_attributes(
- project_id: rspec_coverage.project_id,
- ref_path: rspec_coverage.ref_path,
- param_type: rspec_coverage.param_type,
- last_pipeline_id: new_pipeline.id,
- date: rspec_coverage.date,
- value: 87.0
- )
- end
-
- context 'when given data is empty' do
- it 'does nothing' do
- expect { described_class.upsert_reports([]) }.not_to raise_error
- end
- end
- end
-end
diff --git a/spec/models/ci/freeze_period_spec.rb b/spec/models/ci/freeze_period_spec.rb
new file mode 100644
index 00000000000..f7f840c6696
--- /dev/null
+++ b/spec/models/ci/freeze_period_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::FreezePeriod, type: :model do
+ subject { build(:ci_freeze_period) }
+
+ let(:invalid_cron) { '0 0 0 * *' }
+
+ it { is_expected.to belong_to(:project) }
+
+ it { is_expected.to respond_to(:freeze_start) }
+ it { is_expected.to respond_to(:freeze_end) }
+ it { is_expected.to respond_to(:cron_timezone) }
+
+ describe 'cron validations' do
+ it 'allows valid cron patterns' do
+ freeze_period = build(:ci_freeze_period)
+
+ expect(freeze_period).to be_valid
+ end
+
+ it 'does not allow invalid cron patterns on freeze_start' do
+ freeze_period = build(:ci_freeze_period, freeze_start: invalid_cron)
+
+ expect(freeze_period).not_to be_valid
+ end
+
+ it 'does not allow invalid cron patterns on freeze_end' do
+ freeze_period = build(:ci_freeze_period, freeze_end: invalid_cron)
+
+ expect(freeze_period).not_to be_valid
+ end
+
+ it 'does not allow an invalid timezone' do
+ freeze_period = build(:ci_freeze_period, cron_timezone: 'invalid')
+
+ expect(freeze_period).not_to be_valid
+ end
+
+ context 'when cron contains trailing whitespaces' do
+ it 'strips the attribute' do
+ freeze_period = build(:ci_freeze_period, freeze_start: ' 0 0 * * * ')
+
+ expect(freeze_period).to be_valid
+ expect(freeze_period.freeze_start).to eq('0 0 * * *')
+ end
+ end
+ end
+end
diff --git a/spec/models/ci/freeze_period_status_spec.rb b/spec/models/ci/freeze_period_status_spec.rb
new file mode 100644
index 00000000000..b700ec8c45f
--- /dev/null
+++ b/spec/models/ci/freeze_period_status_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe Ci::FreezePeriodStatus do
+ let(:project) { create :project }
+ # '0 23 * * 5' == "At 23:00 on Friday."", '0 7 * * 1' == "At 07:00 on Monday.""
+ let(:friday_2300) { '0 23 * * 5' }
+ let(:monday_0700) { '0 7 * * 1' }
+
+ subject { described_class.new(project: project).execute }
+
+ shared_examples 'within freeze period' do |time|
+ it 'is frozen' do
+ Timecop.freeze(time) do
+ expect(subject).to be_truthy
+ end
+ end
+ end
+
+ shared_examples 'outside freeze period' do |time|
+ it 'is not frozen' do
+ Timecop.freeze(time) do
+ expect(subject).to be_falsy
+ end
+ end
+ end
+
+ describe 'single freeze period' do
+ let!(:freeze_period) { create(:ci_freeze_period, project: project, freeze_start: friday_2300, freeze_end: monday_0700) }
+
+ it_behaves_like 'outside freeze period', Time.utc(2020, 4, 10, 22, 59)
+
+ it_behaves_like 'within freeze period', Time.utc(2020, 4, 10, 23, 1)
+
+ it_behaves_like 'within freeze period', Time.utc(2020, 4, 13, 6, 59)
+
+ it_behaves_like 'outside freeze period', Time.utc(2020, 4, 13, 7, 1)
+ end
+
+ describe 'multiple freeze periods' do
+ # '30 23 * * 5' == "At 23:30 on Friday."", '0 8 * * 1' == "At 08:00 on Monday.""
+ let(:friday_2330) { '30 23 * * 5' }
+ let(:monday_0800) { '0 8 * * 1' }
+
+ let!(:freeze_period_1) { create(:ci_freeze_period, project: project, freeze_start: friday_2300, freeze_end: monday_0700) }
+ let!(:freeze_period_2) { create(:ci_freeze_period, project: project, freeze_start: friday_2330, freeze_end: monday_0800) }
+
+ it_behaves_like 'outside freeze period', Time.utc(2020, 4, 10, 22, 59)
+
+ it_behaves_like 'within freeze period', Time.utc(2020, 4, 10, 23, 29)
+
+ it_behaves_like 'within freeze period', Time.utc(2020, 4, 11, 10, 0)
+
+ it_behaves_like 'within freeze period', Time.utc(2020, 4, 10, 23, 1)
+
+ it_behaves_like 'within freeze period', Time.utc(2020, 4, 13, 6, 59)
+
+ it_behaves_like 'within freeze period', Time.utc(2020, 4, 13, 7, 59)
+
+ it_behaves_like 'outside freeze period', Time.utc(2020, 4, 13, 8, 1)
+ end
+end
diff --git a/spec/models/ci/instance_variable_spec.rb b/spec/models/ci/instance_variable_spec.rb
new file mode 100644
index 00000000000..ff8676e1424
--- /dev/null
+++ b/spec/models/ci/instance_variable_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::InstanceVariable do
+ subject { build(:ci_instance_variable) }
+
+ it_behaves_like "CI variable"
+
+ it { is_expected.to include_module(Ci::Maskable) }
+ it { is_expected.to validate_uniqueness_of(:key).with_message(/\(\w+\) has already been taken/) }
+
+ describe '.unprotected' do
+ subject { described_class.unprotected }
+
+ context 'when variable is protected' do
+ before do
+ create(:ci_instance_variable, :protected)
+ end
+
+ it 'returns nothing' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when variable is not protected' do
+ let(:variable) { create(:ci_instance_variable, protected: false) }
+
+ it 'returns the variable' do
+ is_expected.to contain_exactly(variable)
+ end
+ end
+ end
+
+ describe '.all_cached', :use_clean_rails_memory_store_caching do
+ let_it_be(:unprotected_variable) { create(:ci_instance_variable, protected: false) }
+ let_it_be(:protected_variable) { create(:ci_instance_variable, protected: true) }
+
+ it { expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable) }
+
+ it 'memoizes the result' do
+ expect(described_class).to receive(:store_cache).with(:ci_instance_variable_data).once.and_call_original
+
+ 2.times do
+ expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable)
+ end
+ end
+
+ it 'removes scopes' do
+ expect(described_class.unprotected.all_cached).to contain_exactly(protected_variable, unprotected_variable)
+ end
+
+ it 'resets the cache when records are deleted' do
+ expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable)
+
+ protected_variable.destroy
+
+ expect(described_class.all_cached).to contain_exactly(unprotected_variable)
+ end
+
+ it 'resets the cache when records are inserted' do
+ expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable)
+
+ variable = create(:ci_instance_variable, protected: true)
+
+ expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable, variable)
+ end
+
+ it 'resets the cache when the shared key is missing' do
+ expect(Rails.cache).to receive(:read).with(:ci_instance_variable_changed_at).twice.and_return(nil)
+ expect(described_class).to receive(:store_cache).with(:ci_instance_variable_data).thrice.and_call_original
+
+ 3.times do
+ expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable)
+ end
+ end
+ end
+
+ describe '.unprotected_cached', :use_clean_rails_memory_store_caching do
+ let_it_be(:unprotected_variable) { create(:ci_instance_variable, protected: false) }
+ let_it_be(:protected_variable) { create(:ci_instance_variable, protected: true) }
+
+ it { expect(described_class.unprotected_cached).to contain_exactly(unprotected_variable) }
+
+ it 'memoizes the result' do
+ expect(described_class).to receive(:store_cache).with(:ci_instance_variable_data).once.and_call_original
+
+ 2.times do
+ expect(described_class.unprotected_cached).to contain_exactly(unprotected_variable)
+ end
+ end
+ end
+end
diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb
index 6f6ff3704b4..4cdc74d7a41 100644
--- a/spec/models/ci/job_artifact_spec.rb
+++ b/spec/models/ci/job_artifact_spec.rb
@@ -19,24 +19,8 @@ describe Ci::JobArtifact do
it_behaves_like 'having unique enum values'
- context 'with update_project_statistics_after_commit enabled' do
- before do
- stub_feature_flags(update_project_statistics_after_commit: true)
- end
-
- it_behaves_like 'UpdateProjectStatistics' do
- subject { build(:ci_job_artifact, :archive, size: 107464) }
- end
- end
-
- context 'with update_project_statistics_after_commit disabled' do
- before do
- stub_feature_flags(update_project_statistics_after_commit: false)
- end
-
- it_behaves_like 'UpdateProjectStatistics' do
- subject { build(:ci_job_artifact, :archive, size: 107464) }
- end
+ it_behaves_like 'UpdateProjectStatistics' do
+ subject { build(:ci_job_artifact, :archive, size: 107464) }
end
describe '.with_reports' do
@@ -70,6 +54,22 @@ describe Ci::JobArtifact do
end
end
+ describe '.accessibility_reports' do
+ subject { described_class.accessibility_reports }
+
+ context 'when there is an accessibility report' do
+ let(:artifact) { create(:ci_job_artifact, :accessibility) }
+
+ it { is_expected.to eq([artifact]) }
+ end
+
+ context 'when there are no accessibility report' do
+ let(:artifact) { create(:ci_job_artifact, :archive) }
+
+ it { is_expected.to be_empty }
+ end
+ end
+
describe '.coverage_reports' do
subject { described_class.coverage_reports }
@@ -86,6 +86,22 @@ describe Ci::JobArtifact do
end
end
+ describe '.terraform_reports' do
+ context 'when there is a terraform report' do
+ it 'return the job artifact' do
+ artifact = create(:ci_job_artifact, :terraform)
+
+ expect(described_class.terraform_reports).to eq([artifact])
+ end
+ end
+
+ context 'when there are no terraform reports' do
+ it 'return the an empty array' do
+ expect(described_class.terraform_reports).to eq([])
+ end
+ end
+ end
+
describe '.erasable' do
subject { described_class.erasable }
@@ -128,15 +144,26 @@ describe Ci::JobArtifact do
end
describe '.for_sha' do
+ let(:first_pipeline) { create(:ci_pipeline) }
+ let(:second_pipeline) { create(:ci_pipeline, project: first_pipeline.project, sha: Digest::SHA1.hexdigest(SecureRandom.hex)) }
+ let!(:first_artifact) { create(:ci_job_artifact, job: create(:ci_build, pipeline: first_pipeline)) }
+ let!(:second_artifact) { create(:ci_job_artifact, job: create(:ci_build, pipeline: second_pipeline)) }
+
it 'returns job artifacts for a given pipeline sha' do
- project = create(:project)
- first_pipeline = create(:ci_pipeline, project: project)
- second_pipeline = create(:ci_pipeline, project: project, sha: Digest::SHA1.hexdigest(SecureRandom.hex))
- first_artifact = create(:ci_job_artifact, job: create(:ci_build, pipeline: first_pipeline))
- second_artifact = create(:ci_job_artifact, job: create(:ci_build, pipeline: second_pipeline))
+ expect(described_class.for_sha(first_pipeline.sha, first_pipeline.project.id)).to eq([first_artifact])
+ expect(described_class.for_sha(second_pipeline.sha, first_pipeline.project.id)).to eq([second_artifact])
+ end
+ end
- expect(described_class.for_sha(first_pipeline.sha, project.id)).to eq([first_artifact])
- expect(described_class.for_sha(second_pipeline.sha, project.id)).to eq([second_artifact])
+ describe '.for_ref' do
+ let(:first_pipeline) { create(:ci_pipeline, ref: 'first_ref') }
+ let(:second_pipeline) { create(:ci_pipeline, ref: 'second_ref', project: first_pipeline.project) }
+ let!(:first_artifact) { create(:ci_job_artifact, job: create(:ci_build, pipeline: first_pipeline)) }
+ let!(:second_artifact) { create(:ci_job_artifact, job: create(:ci_build, pipeline: second_pipeline)) }
+
+ it 'returns job artifacts for a given pipeline ref' do
+ expect(described_class.for_ref(first_pipeline.ref, first_pipeline.project.id)).to eq([first_artifact])
+ expect(described_class.for_ref(second_pipeline.ref, first_pipeline.project.id)).to eq([second_artifact])
end
end
@@ -153,9 +180,9 @@ describe Ci::JobArtifact do
end
describe 'callbacks' do
- subject { create(:ci_job_artifact, :archive) }
-
describe '#schedule_background_upload' do
+ subject { create(:ci_job_artifact, :archive) }
+
context 'when object storage is disabled' do
before do
stub_artifacts_object_storage(enabled: false)
@@ -212,9 +239,35 @@ describe Ci::JobArtifact do
end
end
+ describe 'validates if file format is supported' do
+ subject { artifact }
+
+ let(:artifact) { build(:ci_job_artifact, file_type: :license_management, file_format: :raw) }
+
+ context 'when license_management is supported' do
+ before do
+ stub_feature_flags(drop_license_management_artifact: false)
+ end
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'when license_management is not supported' do
+ before do
+ stub_feature_flags(drop_license_management_artifact: true)
+ end
+
+ it { is_expected.not_to be_valid }
+ end
+ end
+
describe 'validates file format' do
subject { artifact }
+ before do
+ stub_feature_flags(drop_license_management_artifact: false)
+ end
+
described_class::TYPE_AND_FORMAT_PAIRS.except(:trace).each do |file_type, file_format|
context "when #{file_type} type with #{file_format} format" do
let(:artifact) { build(:ci_job_artifact, file_type: file_type, file_format: file_format) }
@@ -351,19 +404,6 @@ describe Ci::JobArtifact do
describe 'file is being stored' do
subject { create(:ci_job_artifact, :archive) }
- context 'when object has nil store' do
- before do
- subject.update_column(:file_store, nil)
- subject.reload
- end
-
- it 'is stored locally' do
- expect(subject.file_store).to be(nil)
- expect(subject.file).to be_file_storage
- expect(subject.file.object_store).to eq(ObjectStorage::Store::LOCAL)
- end
- end
-
context 'when existing object has local store' do
it 'is stored locally' do
expect(subject.file_store).to be(ObjectStorage::Store::LOCAL)
diff --git a/spec/models/ci/persistent_ref_spec.rb b/spec/models/ci/persistent_ref_spec.rb
index 4cece0664cf..89dd9b05331 100644
--- a/spec/models/ci/persistent_ref_spec.rb
+++ b/spec/models/ci/persistent_ref_spec.rb
@@ -45,18 +45,6 @@ describe Ci::PersistentRef do
expect(pipeline.persistent_ref).to be_exist
end
- context 'when depend_on_persistent_pipeline_ref feature flag is disabled' do
- before do
- stub_feature_flags(depend_on_persistent_pipeline_ref: false)
- end
-
- it 'does not create a persistent ref' do
- expect(project.repository).not_to receive(:create_ref)
-
- subject
- end
- end
-
context 'when sha does not exist in the repository' do
let(:sha) { 'not-exist' }
diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb
index 4ed4b7e38d8..9a10c7629b2 100644
--- a/spec/models/ci/pipeline_schedule_spec.rb
+++ b/spec/models/ci/pipeline_schedule_spec.rb
@@ -17,14 +17,18 @@ describe Ci::PipelineSchedule do
it { is_expected.to respond_to(:description) }
it { is_expected.to respond_to(:next_run_at) }
+ it_behaves_like 'includes Limitable concern' do
+ subject { build(:ci_pipeline_schedule) }
+ end
+
describe 'validations' do
- it 'does not allow invalid cron patters' do
+ it 'does not allow invalid cron patterns' do
pipeline_schedule = build(:ci_pipeline_schedule, cron: '0 0 0 * *')
expect(pipeline_schedule).not_to be_valid
end
- it 'does not allow invalid cron patters' do
+ it 'does not allow invalid cron patterns' do
pipeline_schedule = build(:ci_pipeline_schedule, cron_timezone: 'invalid')
expect(pipeline_schedule).not_to be_valid
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 90412136c1d..4f53b6b4418 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -53,6 +53,29 @@ describe Ci::Pipeline, :mailer do
end
end
+ describe '#set_status' do
+ where(:from_status, :to_status) do
+ from_status_names = described_class.state_machines[:status].states.map(&:name)
+ to_status_names = from_status_names - [:created] # we never want to transition into created
+
+ from_status_names.product(to_status_names)
+ end
+
+ with_them do
+ it do
+ pipeline.status = from_status.to_s
+
+ if from_status != to_status
+ expect(pipeline.set_status(to_status.to_s))
+ .to eq(true)
+ else
+ expect(pipeline.set_status(to_status.to_s))
+ .to eq(false), "loopback transitions are not allowed"
+ end
+ end
+ end
+ end
+
describe '.processables' do
before do
create(:ci_build, name: 'build', pipeline: pipeline)
@@ -364,6 +387,26 @@ describe Ci::Pipeline, :mailer do
end
end
+ context 'when pipeline has an accessibility report' do
+ subject { described_class.with_reports(Ci::JobArtifact.accessibility_reports) }
+
+ let(:pipeline_with_report) { create(:ci_pipeline, :with_accessibility_reports) }
+
+ it 'selects the pipeline' do
+ is_expected.to eq([pipeline_with_report])
+ end
+ end
+
+ context 'when pipeline has a terraform report' do
+ it 'selects the pipeline' do
+ pipeline_with_report = create(:ci_pipeline, :with_terraform_reports)
+
+ expect(described_class.with_reports(Ci::JobArtifact.terraform_reports)).to eq(
+ [pipeline_with_report]
+ )
+ end
+ end
+
context 'when pipeline does not have metrics reports' do
subject { described_class.with_reports(Ci::JobArtifact.test_reports) }
@@ -699,6 +742,28 @@ describe Ci::Pipeline, :mailer do
)
end
end
+
+ describe 'variable CI_KUBERNETES_ACTIVE' do
+ context 'when pipeline.has_kubernetes_active? is true' do
+ before do
+ allow(pipeline).to receive(:has_kubernetes_active?).and_return(true)
+ end
+
+ it "is included with value 'true'" do
+ expect(subject.to_hash).to include('CI_KUBERNETES_ACTIVE' => 'true')
+ end
+ end
+
+ context 'when pipeline.has_kubernetes_active? is false' do
+ before do
+ allow(pipeline).to receive(:has_kubernetes_active?).and_return(false)
+ end
+
+ it 'is not included' do
+ expect(subject.to_hash).not_to have_key('CI_KUBERNETES_ACTIVE')
+ end
+ end
+ end
end
describe '#protected_ref?' do
@@ -944,7 +1009,10 @@ describe Ci::Pipeline, :mailer do
context 'when using legacy stages' do
before do
- stub_feature_flags(ci_pipeline_persisted_stages: false)
+ stub_feature_flags(
+ ci_pipeline_persisted_stages: false,
+ ci_atomic_processing: false
+ )
end
it 'returns legacy stages in valid order' do
@@ -952,9 +1020,40 @@ describe Ci::Pipeline, :mailer do
end
end
+ context 'when using atomic processing' do
+ before do
+ stub_feature_flags(
+ ci_atomic_processing: true
+ )
+ end
+
+ context 'when pipelines is not complete' do
+ it 'returns stages in valid order' do
+ expect(subject).to all(be_a Ci::Stage)
+ expect(subject.map(&:name))
+ .to eq %w[sanity build test deploy cleanup]
+ end
+ end
+
+ context 'when pipeline is complete' do
+ before do
+ pipeline.succeed!
+ end
+
+ it 'returns stages in valid order' do
+ expect(subject).to all(be_a Ci::Stage)
+ expect(subject.map(&:name))
+ .to eq %w[sanity build test deploy cleanup]
+ end
+ end
+ end
+
context 'when using persisted stages' do
before do
- stub_feature_flags(ci_pipeline_persisted_stages: true)
+ stub_feature_flags(
+ ci_pipeline_persisted_stages: true,
+ ci_atomic_processing: false
+ )
end
context 'when pipelines is not complete' do
@@ -1119,8 +1218,8 @@ describe Ci::Pipeline, :mailer do
context "from #{status}" do
let(:from_status) { status }
- it 'schedules pipeline success worker' do
- expect(Ci::DailyReportResultsWorker).to receive(:perform_in).with(10.minutes, pipeline.id)
+ it 'schedules daily build group report results worker' do
+ expect(Ci::DailyBuildGroupReportResultsWorker).to receive(:perform_in).with(10.minutes, pipeline.id)
pipeline.succeed
end
@@ -2307,7 +2406,7 @@ describe Ci::Pipeline, :mailer do
def have_requested_pipeline_hook(status)
have_requested(:post, stubbed_hostname(hook.url)).with do |req|
- json_body = JSON.parse(req.body)
+ json_body = Gitlab::Json.parse(req.body)
json_body['object_attributes']['status'] == status &&
json_body['builds'].length == 2
end
@@ -2755,6 +2854,42 @@ describe Ci::Pipeline, :mailer do
end
end
+ describe '#accessibility_reports' do
+ subject { pipeline.accessibility_reports }
+
+ context 'when pipeline has multiple builds with accessibility reports' do
+ let(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project) }
+ let(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline, project: project) }
+
+ before do
+ create(:ci_job_artifact, :accessibility, job: build_rspec, project: project)
+ create(:ci_job_artifact, :accessibility_without_errors, job: build_golang, project: project)
+ end
+
+ it 'returns accessibility report with collected data' do
+ expect(subject.urls.keys).to match_array([
+ "https://pa11y.org/",
+ "https://about.gitlab.com/"
+ ])
+ end
+
+ context 'when builds are retried' do
+ let(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) }
+ let(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline, project: project) }
+
+ it 'returns empty urls for accessibility reports' do
+ expect(subject.urls).to be_empty
+ end
+ end
+ end
+
+ context 'when pipeline does not have any builds with accessibility reports' do
+ it 'returns empty urls for accessibility reports' do
+ expect(subject.urls).to be_empty
+ end
+ end
+ end
+
describe '#coverage_reports' do
subject { pipeline.coverage_reports }
diff --git a/spec/models/ci/processable_spec.rb b/spec/models/ci/processable_spec.rb
index 4490371bde5..e67f740279b 100644
--- a/spec/models/ci/processable_spec.rb
+++ b/spec/models/ci/processable_spec.rb
@@ -6,16 +6,12 @@ describe Ci::Processable do
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
- let_it_be(:detached_merge_request_pipeline) do
- create(:ci_pipeline, :detached_merge_request_pipeline, :with_job, project: project)
- end
-
- let_it_be(:legacy_detached_merge_request_pipeline) do
- create(:ci_pipeline, :legacy_detached_merge_request_pipeline, :with_job, project: project)
- end
+ describe 'delegations' do
+ subject { Ci::Processable.new }
- let_it_be(:merged_result_pipeline) do
- create(:ci_pipeline, :merged_result_pipeline, :with_job, project: project)
+ it { is_expected.to delegate_method(:merge_request?).to(:pipeline) }
+ it { is_expected.to delegate_method(:merge_request_ref?).to(:pipeline) }
+ it { is_expected.to delegate_method(:legacy_detached_merge_request_pipeline?).to(:pipeline) }
end
describe '#aggregated_needs_names' do
@@ -52,69 +48,28 @@ describe Ci::Processable do
end
describe 'validate presence of scheduling_type' do
- context 'on create' do
- let(:processable) do
- build(
- :ci_build, :created, project: project, pipeline: pipeline,
- importing: importing, scheduling_type: nil
- )
- end
-
- context 'when importing' do
- let(:importing) { true }
-
- context 'when validate_scheduling_type_of_processables is true' do
- before do
- stub_feature_flags(validate_scheduling_type_of_processables: true)
- end
+ using RSpec::Parameterized::TableSyntax
- it 'does not validate' do
- expect(processable).to be_valid
- end
- end
-
- context 'when validate_scheduling_type_of_processables is false' do
- before do
- stub_feature_flags(validate_scheduling_type_of_processables: false)
- end
-
- it 'does not validate' do
- expect(processable).to be_valid
- end
- end
- end
+ subject { build(:ci_build, project: project, pipeline: pipeline, importing: importing) }
- context 'when not importing' do
- let(:importing) { false }
-
- context 'when validate_scheduling_type_of_processables is true' do
- before do
- stub_feature_flags(validate_scheduling_type_of_processables: true)
- end
-
- it 'validates' do
- expect(processable).not_to be_valid
- end
- end
-
- context 'when validate_scheduling_type_of_processables is false' do
- before do
- stub_feature_flags(validate_scheduling_type_of_processables: false)
- end
+ where(:importing, :should_validate) do
+ false | true
+ true | false
+ end
- it 'does not validate' do
- expect(processable).to be_valid
+ with_them do
+ context 'on create' do
+ it 'validates presence' do
+ if should_validate
+ is_expected.to validate_presence_of(:scheduling_type).on(:create)
+ else
+ is_expected.not_to validate_presence_of(:scheduling_type).on(:create)
end
end
end
- end
-
- context 'on update' do
- let(:processable) { create(:ci_build, :created, project: project, pipeline: pipeline) }
- it 'does not validate' do
- processable.scheduling_type = nil
- expect(processable).to be_valid
+ context 'on update' do
+ it { is_expected.not_to validate_presence_of(:scheduling_type).on(:update) }
end
end
end
@@ -147,6 +102,8 @@ describe Ci::Processable do
describe '#needs_attributes' do
let(:build) { create(:ci_build, :created, project: project, pipeline: pipeline) }
+ subject { build.needs_attributes }
+
context 'with needs' do
before do
create(:ci_build_need, build: build, name: 'test1')
@@ -154,7 +111,7 @@ describe Ci::Processable do
end
it 'returns all needs attributes' do
- expect(build.needs_attributes).to contain_exactly(
+ is_expected.to contain_exactly(
{ 'artifacts' => true, 'name' => 'test1' },
{ 'artifacts' => true, 'name' => 'test2' }
)
@@ -162,75 +119,7 @@ describe Ci::Processable do
end
context 'without needs' do
- it 'returns all needs attributes' do
- expect(build.needs_attributes).to be_empty
- end
- end
- end
-
- describe '#merge_request?' do
- subject { pipeline.processables.first.merge_request? }
-
- context 'in a detached merge request pipeline' do
- let(:pipeline) { detached_merge_request_pipeline }
-
- it { is_expected.to eq(pipeline.merge_request?) }
- end
-
- context 'in a legacy detached merge_request_pipeline' do
- let(:pipeline) { legacy_detached_merge_request_pipeline }
-
- it { is_expected.to eq(pipeline.merge_request?) }
- end
-
- context 'in a pipeline for merged results' do
- let(:pipeline) { merged_result_pipeline }
-
- it { is_expected.to eq(pipeline.merge_request?) }
- end
- end
-
- describe '#merge_request_ref?' do
- subject { pipeline.processables.first.merge_request_ref? }
-
- context 'in a detached merge request pipeline' do
- let(:pipeline) { detached_merge_request_pipeline }
-
- it { is_expected.to eq(pipeline.merge_request_ref?) }
- end
-
- context 'in a legacy detached merge_request_pipeline' do
- let(:pipeline) { legacy_detached_merge_request_pipeline }
-
- it { is_expected.to eq(pipeline.merge_request_ref?) }
- end
-
- context 'in a pipeline for merged results' do
- let(:pipeline) { merged_result_pipeline }
-
- it { is_expected.to eq(pipeline.merge_request_ref?) }
- end
- end
-
- describe '#legacy_detached_merge_request_pipeline?' do
- subject { pipeline.processables.first.legacy_detached_merge_request_pipeline? }
-
- context 'in a detached merge request pipeline' do
- let(:pipeline) { detached_merge_request_pipeline }
-
- it { is_expected.to eq(pipeline.legacy_detached_merge_request_pipeline?) }
- end
-
- context 'in a legacy detached merge_request_pipeline' do
- let(:pipeline) { legacy_detached_merge_request_pipeline }
-
- it { is_expected.to eq(pipeline.legacy_detached_merge_request_pipeline?) }
- end
-
- context 'in a pipeline for merged results' do
- let(:pipeline) { merged_result_pipeline }
-
- it { is_expected.to eq(pipeline.legacy_detached_merge_request_pipeline?) }
+ it { is_expected.to be_empty }
end
end
end
diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb
index 2dedff7f15b..8b6a4fa6ade 100644
--- a/spec/models/ci/runner_spec.rb
+++ b/spec/models/ci/runner_spec.rb
@@ -270,7 +270,7 @@ describe Ci::Runner do
it { is_expected.to eq([@runner2])}
end
- describe '#online?' do
+ describe '#online?', :clean_gitlab_redis_cache do
let(:runner) { create(:ci_runner, :instance) }
subject { runner.online? }
@@ -332,7 +332,7 @@ describe Ci::Runner do
end
def stub_redis_runner_contacted_at(value)
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
cache_key = runner.send(:cache_attribute_key)
expect(redis).to receive(:get).with(cache_key)
.and_return({ contacted_at: value }.to_json).at_least(:once)
@@ -640,7 +640,7 @@ describe Ci::Runner do
end
def expect_redis_update
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
redis_key = runner.send(:cache_attribute_key)
expect(redis).to receive(:set).with(redis_key, anything, any_args)
end
@@ -664,7 +664,7 @@ describe Ci::Runner do
end
it 'cleans up the queue' do
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
expect(redis.get(queue_key)).to be_nil
end
end
diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb
index 3aeaa27abce..a1549532559 100644
--- a/spec/models/ci/stage_spec.rb
+++ b/spec/models/ci/stage_spec.rb
@@ -3,7 +3,8 @@
require 'spec_helper'
describe Ci::Stage, :models do
- let(:stage) { create(:ci_stage_entity) }
+ let_it_be(:pipeline) { create(:ci_empty_pipeline) }
+ let(:stage) { create(:ci_stage_entity, pipeline: pipeline, project: pipeline.project) }
it_behaves_like 'having unique enum values'
@@ -55,6 +56,29 @@ describe Ci::Stage, :models do
end
end
+ describe '#set_status' do
+ where(:from_status, :to_status) do
+ from_status_names = described_class.state_machines[:status].states.map(&:name)
+ to_status_names = from_status_names - [:created] # we never want to transition into created
+
+ from_status_names.product(to_status_names)
+ end
+
+ with_them do
+ it do
+ stage.status = from_status.to_s
+
+ if from_status != to_status
+ expect(stage.set_status(to_status.to_s))
+ .to eq(true)
+ else
+ expect(stage.set_status(to_status.to_s))
+ .to eq(false), "loopback transitions are not allowed"
+ end
+ end
+ end
+ end
+
describe '#update_status' do
context 'when stage objects needs to be updated' do
before do
diff --git a/spec/models/clusters/applications/elastic_stack_spec.rb b/spec/models/clusters/applications/elastic_stack_spec.rb
index b0992c43d11..02ada219e32 100644
--- a/spec/models/clusters/applications/elastic_stack_spec.rb
+++ b/spec/models/clusters/applications/elastic_stack_spec.rb
@@ -19,10 +19,12 @@ describe Clusters::Applications::ElasticStack do
it 'is initialized with elastic stack arguments' do
expect(subject.name).to eq('elastic-stack')
- expect(subject.chart).to eq('stable/elastic-stack')
- expect(subject.version).to eq('1.9.0')
+ expect(subject.chart).to eq('elastic-stack/elastic-stack')
+ expect(subject.version).to eq('3.0.0')
+ expect(subject.repository).to eq('https://charts.gitlab.io')
expect(subject).to be_rbac
expect(subject.files).to eq(elastic_stack.files)
+ expect(subject.preinstall).to be_empty
end
context 'on a non rbac enabled cluster' do
@@ -33,15 +35,75 @@ describe Clusters::Applications::ElasticStack do
it { is_expected.not_to be_rbac }
end
+ context 'on versions older than 2' do
+ before do
+ elastic_stack.status = elastic_stack.status_states[:updating]
+ elastic_stack.version = "1.9.0"
+ end
+
+ it 'includes a preinstall script' do
+ expect(subject.preinstall).not_to be_empty
+ expect(subject.preinstall.first).to include("delete")
+ end
+ end
+
+ context 'on versions older than 3' do
+ before do
+ elastic_stack.status = elastic_stack.status_states[:updating]
+ elastic_stack.version = "2.9.0"
+ end
+
+ it 'includes a preinstall script' do
+ expect(subject.preinstall).not_to be_empty
+ expect(subject.preinstall.first).to include("delete")
+ end
+ end
+
context 'application failed to install previously' do
let(:elastic_stack) { create(:clusters_applications_elastic_stack, :errored, version: '0.0.1') }
it 'is initialized with the locked version' do
- expect(subject.version).to eq('1.9.0')
+ expect(subject.version).to eq('3.0.0')
end
end
end
+ describe '#chart_above_v2?' do
+ let(:elastic_stack) { create(:clusters_applications_elastic_stack, version: version) }
+
+ subject { elastic_stack.chart_above_v2? }
+
+ context 'on v1.9.0' do
+ let(:version) { '1.9.0' }
+
+ it { is_expected.to be_falsy }
+ end
+
+ context 'on v2.0.0' do
+ let(:version) { '2.0.0' }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ describe '#chart_above_v3?' do
+ let(:elastic_stack) { create(:clusters_applications_elastic_stack, version: version) }
+
+ subject { elastic_stack.chart_above_v3? }
+
+ context 'on v1.9.0' do
+ let(:version) { '1.9.0' }
+
+ it { is_expected.to be_falsy }
+ end
+
+ context 'on v3.0.0' do
+ let(:version) { '3.0.0' }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
describe '#uninstall_command' do
let!(:elastic_stack) { create(:clusters_applications_elastic_stack) }
@@ -57,7 +119,7 @@ describe Clusters::Applications::ElasticStack do
it 'specifies a post delete command to remove custom resource definitions' do
expect(subject.postdelete).to eq([
- 'kubectl delete pvc --selector release\\=elastic-stack'
+ 'kubectl delete pvc --selector app\\=elastic-stack-elasticsearch-master --namespace gitlab-managed-apps'
])
end
end
diff --git a/spec/models/clusters/applications/fluentd_spec.rb b/spec/models/clusters/applications/fluentd_spec.rb
index 7e9680b0ab4..4e9548990ed 100644
--- a/spec/models/clusters/applications/fluentd_spec.rb
+++ b/spec/models/clusters/applications/fluentd_spec.rb
@@ -3,7 +3,9 @@
require 'spec_helper'
describe Clusters::Applications::Fluentd do
- let(:fluentd) { create(:clusters_applications_fluentd) }
+ let(:waf_log_enabled) { true }
+ let(:cilium_log_enabled) { true }
+ let(:fluentd) { create(:clusters_applications_fluentd, waf_log_enabled: waf_log_enabled, cilium_log_enabled: cilium_log_enabled) }
include_examples 'cluster application core specs', :clusters_applications_fluentd
include_examples 'cluster application status specs', :clusters_applications_fluentd
@@ -47,4 +49,36 @@ describe Clusters::Applications::Fluentd do
expect(values).to include('output.conf', 'general.conf')
end
end
+
+ describe '#values' do
+ let(:modsecurity_log_path) { "/var/log/containers/*#{Clusters::Applications::Ingress::MODSECURITY_LOG_CONTAINER_NAME}*.log" }
+ let(:cilium_log_path) { "/var/log/containers/*#{described_class::CILIUM_CONTAINER_NAME}*.log" }
+
+ subject { fluentd.values }
+
+ context 'with both logs variables set to false' do
+ let(:waf_log_enabled) { false }
+ let(:cilium_log_enabled) { false }
+
+ it "raises ActiveRecord::RecordInvalid" do
+ expect {subject}.to raise_error(ActiveRecord::RecordInvalid)
+ end
+ end
+
+ context 'with both logs variables set to true' do
+ it { is_expected.to include("#{modsecurity_log_path},#{cilium_log_path}") }
+ end
+
+ context 'with waf_log_enabled set to true' do
+ let(:cilium_log_enabled) { false }
+
+ it { is_expected.to include(modsecurity_log_path) }
+ end
+
+ context 'with cilium_log_enabled set to true' do
+ let(:waf_log_enabled) { false }
+
+ it { is_expected.to include(cilium_log_path) }
+ end
+ end
end
diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb
index b070729ccac..8aee4eec0d3 100644
--- a/spec/models/clusters/applications/ingress_spec.rb
+++ b/spec/models/clusters/applications/ingress_spec.rb
@@ -220,6 +220,12 @@ describe Clusters::Applications::Ingress do
expect(subject.values).to include('extraContainers')
end
+ it 'executes command to tail modsecurity logs with -F option' do
+ args = YAML.safe_load(subject.values).dig('controller', 'extraContainers', 0, 'args')
+
+ expect(args).to eq(['/bin/sh', '-c', 'tail -F /var/log/modsec/audit.log'])
+ end
+
it 'includes livenessProbe for modsecurity sidecar container' do
probe_config = YAML.safe_load(subject.values).dig('controller', 'extraContainers', 0, 'livenessProbe')
diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb
index 3bc5088d1ab..937db9217f3 100644
--- a/spec/models/clusters/applications/jupyter_spec.rb
+++ b/spec/models/clusters/applications/jupyter_spec.rb
@@ -57,7 +57,7 @@ describe Clusters::Applications::Jupyter do
it 'is initialized with 4 arguments' do
expect(subject.name).to eq('jupyter')
expect(subject.chart).to eq('jupyter/jupyterhub')
- expect(subject.version).to eq('0.9.0-beta.2')
+ expect(subject.version).to eq('0.9.0')
expect(subject).to be_rbac
expect(subject.repository).to eq('https://jupyterhub.github.io/helm-chart/')
@@ -76,7 +76,7 @@ describe Clusters::Applications::Jupyter do
let(:jupyter) { create(:clusters_applications_jupyter, :errored, version: '0.0.1') }
it 'is initialized with the locked version' do
- expect(subject.version).to eq('0.9.0-beta.2')
+ expect(subject.version).to eq('0.9.0')
end
end
end
diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb
index db1d8672d1e..521ed98f637 100644
--- a/spec/models/clusters/cluster_spec.rb
+++ b/spec/models/clusters/cluster_spec.rb
@@ -590,6 +590,60 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
end
end
+ describe '#find_or_build_application' do
+ let_it_be(:cluster, reload: true) { create(:cluster) }
+
+ it 'rejects classes that are not applications' do
+ expect do
+ cluster.find_or_build_application(Project)
+ end.to raise_error(ArgumentError)
+ end
+
+ context 'when none of applications are created' do
+ it 'returns the new application', :aggregate_failures do
+ described_class::APPLICATIONS.values.each do |application_class|
+ application = cluster.find_or_build_application(application_class)
+
+ expect(application).to be_a(application_class)
+ expect(application).not_to be_persisted
+ end
+ end
+ end
+
+ context 'when application is persisted' do
+ let!(:helm) { create(:clusters_applications_helm, cluster: cluster) }
+ let!(:ingress) { create(:clusters_applications_ingress, cluster: cluster) }
+ let!(:cert_manager) { create(:clusters_applications_cert_manager, cluster: cluster) }
+ let!(:crossplane) { create(:clusters_applications_crossplane, cluster: cluster) }
+ let!(:prometheus) { create(:clusters_applications_prometheus, cluster: cluster) }
+ let!(:runner) { create(:clusters_applications_runner, cluster: cluster) }
+ let!(:jupyter) { create(:clusters_applications_jupyter, cluster: cluster) }
+ let!(:knative) { create(:clusters_applications_knative, cluster: cluster) }
+ let!(:elastic_stack) { create(:clusters_applications_elastic_stack, cluster: cluster) }
+ let!(:fluentd) { create(:clusters_applications_fluentd, cluster: cluster) }
+
+ it 'returns the persisted application', :aggregate_failures do
+ {
+ Clusters::Applications::Helm => helm,
+ Clusters::Applications::Ingress => ingress,
+ Clusters::Applications::CertManager => cert_manager,
+ Clusters::Applications::Crossplane => crossplane,
+ Clusters::Applications::Prometheus => prometheus,
+ Clusters::Applications::Runner => runner,
+ Clusters::Applications::Jupyter => jupyter,
+ Clusters::Applications::Knative => knative,
+ Clusters::Applications::ElasticStack => elastic_stack,
+ Clusters::Applications::Fluentd => fluentd
+ }.each do |application_class, expected_object|
+ application = cluster.find_or_build_application(application_class)
+
+ expect(application).to eq(expected_object)
+ expect(application).to be_persisted
+ end
+ end
+ end
+ end
+
describe '#allow_user_defined_namespace?' do
subject { cluster.allow_user_defined_namespace? }
@@ -889,9 +943,9 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
end
describe '#make_cleanup_errored!' do
- NON_ERRORED_STATES = Clusters::Cluster.state_machines[:cleanup_status].states.keys - [:cleanup_errored]
+ non_errored_states = Clusters::Cluster.state_machines[:cleanup_status].states.keys - [:cleanup_errored]
- NON_ERRORED_STATES.each do |state|
+ non_errored_states.each do |state|
it "transitions cleanup_status from #{state} to cleanup_errored" do
cluster = create(:cluster, state)
@@ -948,6 +1002,22 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
end
end
+ describe '#nodes' do
+ let(:cluster) { create(:cluster) }
+
+ subject { cluster.nodes }
+
+ it { is_expected.to be_nil }
+
+ context 'with a cached status' do
+ before do
+ stub_reactive_cache(cluster, nodes: [kube_node])
+ end
+
+ it { is_expected.to eq([kube_node]) }
+ end
+ end
+
describe '#calculate_reactive_cache' do
subject { cluster.calculate_reactive_cache }
@@ -956,6 +1026,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
it 'does not populate the cache' do
expect(cluster).not_to receive(:retrieve_connection_status)
+ expect(cluster).not_to receive(:retrieve_nodes)
is_expected.to be_nil
end
@@ -964,12 +1035,12 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
context 'cluster is enabled' do
let(:cluster) { create(:cluster, :provided_by_user, :group) }
- context 'connection to the cluster is successful' do
- before do
- stub_kubeclient_discover(cluster.platform.api_url)
- end
+ before do
+ stub_kubeclient_nodes_and_nodes_metrics(cluster.platform.api_url)
+ end
- it { is_expected.to eq(connection_status: :connected) }
+ context 'connection to the cluster is successful' do
+ it { is_expected.to eq(connection_status: :connected, nodes: [kube_node.merge(kube_node_metrics)]) }
end
context 'cluster cannot be reached' do
@@ -978,7 +1049,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
.and_raise(SocketError)
end
- it { is_expected.to eq(connection_status: :unreachable) }
+ it { is_expected.to eq(connection_status: :unreachable, nodes: []) }
end
context 'cluster cannot be authenticated to' do
@@ -987,7 +1058,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
.and_raise(OpenSSL::X509::CertificateError.new("Certificate error"))
end
- it { is_expected.to eq(connection_status: :authentication_failure) }
+ it { is_expected.to eq(connection_status: :authentication_failure, nodes: []) }
end
describe 'Kubeclient::HttpError' do
@@ -999,18 +1070,18 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
.and_raise(Kubeclient::HttpError.new(error_code, error_message, nil))
end
- it { is_expected.to eq(connection_status: :authentication_failure) }
+ it { is_expected.to eq(connection_status: :authentication_failure, nodes: []) }
context 'generic timeout' do
let(:error_message) { 'Timed out connecting to server'}
- it { is_expected.to eq(connection_status: :unreachable) }
+ it { is_expected.to eq(connection_status: :unreachable, nodes: []) }
end
context 'gateway timeout' do
let(:error_message) { '504 Gateway Timeout for GET https://kubernetes.example.com/api/v1'}
- it { is_expected.to eq(connection_status: :unreachable) }
+ it { is_expected.to eq(connection_status: :unreachable, nodes: []) }
end
end
@@ -1020,11 +1091,12 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
.and_raise(StandardError)
end
- it { is_expected.to eq(connection_status: :unknown_failure) }
+ it { is_expected.to eq(connection_status: :unknown_failure, nodes: []) }
it 'notifies Sentry' do
expect(Gitlab::ErrorTracking).to receive(:track_exception)
.with(instance_of(StandardError), hash_including(cluster_id: cluster.id))
+ .twice
subject
end
diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb
index 73b81b2225a..05d3329215a 100644
--- a/spec/models/commit_status_spec.rb
+++ b/spec/models/commit_status_spec.rb
@@ -751,4 +751,48 @@ describe CommitStatus do
it { is_expected.to be_a(CommitStatusPresenter) }
end
+
+ describe '#recoverable?' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:commit_status) { create(:commit_status, :pending) }
+
+ subject(:recoverable?) { commit_status.recoverable? }
+
+ context 'when commit status is failed' do
+ before do
+ commit_status.drop!
+ end
+
+ where(:failure_reason, :recoverable) do
+ :script_failure | false
+ :missing_dependency_failure | false
+ :archived_failure | false
+ :scheduler_failure | false
+ :data_integrity_failure | false
+ :unknown_failure | true
+ :api_failure | true
+ :stuck_or_timeout_failure | true
+ :runner_system_failure | true
+ end
+
+ with_them do
+ context "when failure reason is #{params[:failure_reason]}" do
+ before do
+ commit_status.update_attribute(:failure_reason, failure_reason)
+ end
+
+ it { is_expected.to eq(recoverable) }
+ end
+ end
+ end
+
+ context 'when commit status is not failed' do
+ before do
+ commit_status.success!
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
end
diff --git a/spec/models/concerns/awardable_spec.rb b/spec/models/concerns/awardable_spec.rb
index 76da42cf243..29f911fcb04 100644
--- a/spec/models/concerns/awardable_spec.rb
+++ b/spec/models/concerns/awardable_spec.rb
@@ -91,4 +91,45 @@ describe Awardable do
expect(issue.award_emoji).to eq issue.award_emoji.sort_by(&:id)
end
end
+
+ describe "#grouped_awards" do
+ context 'default award emojis' do
+ let(:issue_without_downvote) { create(:issue) }
+ let(:issue_with_downvote) do
+ issue_with_downvote = create(:issue)
+ create(:award_emoji, :downvote, awardable: issue_with_downvote)
+ issue_with_downvote
+ end
+
+ it "includes unused thumbs buttons by default" do
+ expect(issue_without_downvote.grouped_awards.keys.sort).to eq %w(thumbsdown thumbsup)
+ end
+
+ it "doesn't include unused thumbs buttons when disabled in project" do
+ issue_without_downvote.project.show_default_award_emojis = false
+
+ expect(issue_without_downvote.grouped_awards.keys.sort).to eq []
+ end
+
+ it "includes unused thumbs buttons when enabled in project" do
+ issue_without_downvote.project.show_default_award_emojis = true
+
+ expect(issue_without_downvote.grouped_awards.keys.sort).to eq %w(thumbsdown thumbsup)
+ end
+
+ it "doesn't include unused thumbs buttons in summary" do
+ expect(issue_without_downvote.grouped_awards(with_thumbs: false).keys).to eq []
+ end
+
+ it "includes used thumbs buttons when disabled in project" do
+ issue_with_downvote.project.show_default_award_emojis = false
+
+ expect(issue_with_downvote.grouped_awards.keys).to eq %w(thumbsdown)
+ end
+
+ it "includes used thumbs buttons in summary" do
+ expect(issue_with_downvote.grouped_awards(with_thumbs: false).keys).to eq %w(thumbsdown)
+ end
+ end
+ end
end
diff --git a/spec/models/concerns/blocks_json_serialization_spec.rb b/spec/models/concerns/blocks_json_serialization_spec.rb
index 0ef5be3cb61..32870461019 100644
--- a/spec/models/concerns/blocks_json_serialization_spec.rb
+++ b/spec/models/concerns/blocks_json_serialization_spec.rb
@@ -3,8 +3,11 @@
require 'spec_helper'
describe BlocksJsonSerialization do
- DummyModel = Class.new do
- include BlocksJsonSerialization
+ before do
+ stub_const('DummyModel', Class.new)
+ DummyModel.class_eval do
+ include BlocksJsonSerialization
+ end
end
it 'blocks as_json' do
diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb
index 697a9e98505..193144fcb0e 100644
--- a/spec/models/concerns/cache_markdown_field_spec.rb
+++ b/spec/models/concerns/cache_markdown_field_spec.rb
@@ -223,6 +223,10 @@ describe CacheMarkdownField, :clean_gitlab_redis_cache do
end
context 'when the markdown cache is up to date' do
+ before do
+ thing.try(:save)
+ end
+
it 'does not call #refresh_markdown_cache' do
expect(thing).not_to receive(:refresh_markdown_cache)
@@ -256,6 +260,54 @@ describe CacheMarkdownField, :clean_gitlab_redis_cache do
let(:klass) { ar_class }
it_behaves_like 'a class with cached markdown fields'
+
+ describe '#attribute_invalidated?' do
+ let(:thing) { klass.create(description: markdown, description_html: html, cached_markdown_version: cache_version) }
+
+ it 'returns true when cached_markdown_version is different' do
+ thing.cached_markdown_version += 1
+
+ expect(thing.attribute_invalidated?(:description_html)).to eq(true)
+ end
+
+ it 'returns true when markdown is changed' do
+ thing.description = updated_markdown
+
+ expect(thing.attribute_invalidated?(:description_html)).to eq(true)
+ end
+
+ it 'returns true when both markdown and HTML are changed' do
+ thing.description = updated_markdown
+ thing.description_html = updated_html
+
+ expect(thing.attribute_invalidated?(:description_html)).to eq(true)
+ end
+
+ it 'returns false when there are no changes' do
+ expect(thing.attribute_invalidated?(:description_html)).to eq(false)
+ end
+ end
+
+ context 'when cache version is updated' do
+ let(:old_version) { cache_version - 1 }
+ let(:old_html) { '<p data-sourcepos="1:1-1:5" dir="auto" class="some-old-class"><code>Foo</code></p>' }
+
+ let(:thing) do
+ # This forces the record to have outdated HTML. We can't use `create` because the `before_create` hook
+ # would re-render the HTML to the latest version
+ klass.create.tap do |thing|
+ thing.update_columns(description: markdown, description_html: old_html, cached_markdown_version: old_version)
+ end
+ end
+
+ it 'correctly updates cached HTML even if refresh_markdown_cache is called before updating the attribute' do
+ thing.refresh_markdown_cache
+
+ thing.update(description: updated_markdown)
+
+ expect(thing.description_html).to eq(updated_html)
+ end
+ end
end
context 'for other classes' do
diff --git a/spec/models/concerns/cacheable_attributes_spec.rb b/spec/models/concerns/cacheable_attributes_spec.rb
index d8f940a808e..56e0d044247 100644
--- a/spec/models/concerns/cacheable_attributes_spec.rb
+++ b/spec/models/concerns/cacheable_attributes_spec.rb
@@ -205,11 +205,11 @@ describe CacheableAttributes do
end
end
- it 'uses RequestStore in addition to Thread memory cache', :request_store do
+ it 'uses RequestStore in addition to process memory cache', :request_store do
# Warm up the cache
create(:application_setting).cache!
- expect(ApplicationSetting.cache_backend).to eq(Gitlab::ThreadMemoryCache.cache_backend)
+ expect(ApplicationSetting.cache_backend).to eq(Gitlab::ProcessMemoryCache.cache_backend)
expect(ApplicationSetting.cache_backend).to receive(:read).with(ApplicationSetting.cache_key).once.and_call_original
2.times { ApplicationSetting.current }
diff --git a/spec/models/concerns/has_user_type_spec.rb b/spec/models/concerns/has_user_type_spec.rb
new file mode 100644
index 00000000000..f12eee414f9
--- /dev/null
+++ b/spec/models/concerns/has_user_type_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe User do
+ specify 'types consistency checks', :aggregate_failures do
+ expect(described_class::USER_TYPES.keys)
+ .to match_array(%w[human ghost alert_bot project_bot support_bot service_user visual_review_bot migration_bot])
+ expect(described_class::USER_TYPES).to include(*described_class::BOT_USER_TYPES)
+ expect(described_class::USER_TYPES).to include(*described_class::NON_INTERNAL_USER_TYPES)
+ expect(described_class::USER_TYPES).to include(*described_class::INTERNAL_USER_TYPES)
+ end
+
+ describe 'scopes & predicates' do
+ User::USER_TYPES.keys.each do |type|
+ let_it_be(type) { create(:user, username: type, user_type: type) }
+ end
+ let(:bots) { User::BOT_USER_TYPES.map { |type| public_send(type) } }
+ let(:non_internal) { User::NON_INTERNAL_USER_TYPES.map { |type| public_send(type) } }
+ let(:everyone) { User::USER_TYPES.keys.map { |type| public_send(type) } }
+
+ describe '.humans' do
+ it 'includes humans only' do
+ expect(described_class.humans).to match_array([human])
+ end
+ end
+
+ describe '.bots' do
+ it 'includes all bots' do
+ expect(described_class.bots).to match_array(bots)
+ end
+ end
+
+ describe '.bots_without_project_bot' do
+ it 'includes all bots except project_bot' do
+ expect(described_class.bots_without_project_bot).to match_array(bots - [project_bot])
+ end
+ end
+
+ describe '.non_internal' do
+ it 'includes all non_internal users' do
+ expect(described_class.non_internal).to match_array(non_internal)
+ end
+ end
+
+ describe '.without_ghosts' do
+ it 'includes everyone except ghosts' do
+ expect(described_class.without_ghosts).to match_array(everyone - [ghost])
+ end
+ end
+
+ describe '.without_project_bot' do
+ it 'includes everyone except project_bot' do
+ expect(described_class.without_project_bot).to match_array(everyone - [project_bot])
+ end
+ end
+
+ describe '#bot?' do
+ it 'is true for all bot user types and false for others' do
+ expect(bots).to all(be_bot)
+
+ (everyone - bots).each do |user|
+ expect(user).not_to be_bot
+ end
+ end
+ end
+
+ describe '#human?' do
+ it 'is true for humans only' do
+ expect(human).to be_human
+ expect(alert_bot).not_to be_human
+ expect(User.new).to be_human
+ end
+ end
+
+ describe '#internal?' do
+ it 'is true for all internal user types and false for others' do
+ expect(everyone - non_internal).to all(be_internal)
+
+ non_internal.each do |user|
+ expect(user).not_to be_internal
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb
index 13a3d1cdd82..03fd1c69654 100644
--- a/spec/models/concerns/mentionable_spec.rb
+++ b/spec/models/concerns/mentionable_spec.rb
@@ -3,14 +3,17 @@
require 'spec_helper'
describe Mentionable do
- class Example
- include Mentionable
+ before do
+ stub_const('Example', Class.new)
+ Example.class_eval do
+ include Mentionable
- attr_accessor :project, :message
- attr_mentionable :message
+ attr_accessor :project, :message
+ attr_mentionable :message
- def author
- nil
+ def author
+ nil
+ end
end
end
@@ -28,11 +31,11 @@ describe Mentionable do
end
describe '#any_mentionable_attributes_changed?' do
- Message = Struct.new(:text)
+ message = Struct.new(:text)
let(:mentionable) { Example.new }
let(:changes) do
- msg = Message.new('test')
+ msg = message.new('test')
changes = {}
changes[msg] = ['', 'some message']
@@ -325,3 +328,36 @@ describe Snippet, 'Mentionable' do
end
end
end
+
+describe PersonalSnippet, 'Mentionable' do
+ describe '#store_mentions!' do
+ it_behaves_like 'mentions in description', :personal_snippet
+ it_behaves_like 'mentions in notes', :personal_snippet do
+ let(:note) { create(:note_on_personal_snippet) }
+ let(:mentionable) { note.noteable }
+ end
+ end
+
+ describe 'load mentions' do
+ it_behaves_like 'load mentions from DB', :personal_snippet do
+ let(:note) { create(:note_on_personal_snippet) }
+ let(:mentionable) { note.noteable }
+ end
+ end
+end
+
+describe DesignManagement::Design do
+ describe '#store_mentions!' do
+ it_behaves_like 'mentions in notes', :design do
+ let(:note) { create(:diff_note_on_design) }
+ let(:mentionable) { note.noteable }
+ end
+ end
+
+ describe 'load mentions' do
+ it_behaves_like 'load mentions from DB', :design do
+ let(:note) { create(:diff_note_on_design) }
+ let(:mentionable) { note.noteable }
+ end
+ end
+end
diff --git a/spec/models/concerns/noteable_spec.rb b/spec/models/concerns/noteable_spec.rb
index 097bc24d90f..5c8c5425ca7 100644
--- a/spec/models/concerns/noteable_spec.rb
+++ b/spec/models/concerns/noteable_spec.rb
@@ -241,7 +241,7 @@ describe Noteable do
describe '.resolvable_types' do
it 'exposes the replyable types' do
- expect(described_class.resolvable_types).to include('MergeRequest')
+ expect(described_class.resolvable_types).to include('MergeRequest', 'DesignManagement::Design')
end
end
diff --git a/spec/models/concerns/reactive_caching_spec.rb b/spec/models/concerns/reactive_caching_spec.rb
index 96a9c317fb8..cfca383e0b0 100644
--- a/spec/models/concerns/reactive_caching_spec.rb
+++ b/spec/models/concerns/reactive_caching_spec.rb
@@ -6,39 +6,47 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do
include ExclusiveLeaseHelpers
include ReactiveCachingHelpers
- class CacheTest
- include ReactiveCaching
+ let(:cache_class_test) do
+ Class.new do
+ include ReactiveCaching
- self.reactive_cache_key = ->(thing) { ["foo", thing.id] }
+ self.reactive_cache_key = ->(thing) { ["foo", thing.id] }
- self.reactive_cache_lifetime = 5.minutes
- self.reactive_cache_refresh_interval = 15.seconds
+ self.reactive_cache_lifetime = 5.minutes
+ self.reactive_cache_refresh_interval = 15.seconds
- attr_reader :id
+ attr_reader :id
- def self.primary_key
- :id
- end
+ def self.primary_key
+ :id
+ end
- def initialize(id, &blk)
- @id = id
- @calculator = blk
- end
+ def initialize(id, &blk)
+ @id = id
+ @calculator = blk
+ end
- def calculate_reactive_cache
- @calculator.call
- end
+ def calculate_reactive_cache
+ @calculator.call
+ end
- def result
- with_reactive_cache do |data|
- data
+ def result
+ with_reactive_cache do |data|
+ data
+ end
end
end
end
+ let(:external_dependency_cache_class_test) do
+ Class.new(cache_class_test) do
+ self.reactive_cache_work_type = :external_dependency
+ end
+ end
+
let(:calculation) { -> { 2 + 2 } }
let(:cache_key) { "foo:666" }
- let(:instance) { CacheTest.new(666, &calculation) }
+ let(:instance) { cache_class_test.new(666, &calculation) }
describe '#with_reactive_cache' do
before do
@@ -47,6 +55,18 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do
subject(:go!) { instance.result }
+ shared_examples 'reactive worker call' do |worker_class|
+ let(:instance) do
+ test_class.new(666, &calculation)
+ end
+
+ it 'performs caching with correct worker' do
+ expect(worker_class).to receive(:perform_async).with(test_class, 666)
+
+ go!
+ end
+ end
+
shared_examples 'a cacheable value' do |cached_value|
before do
stub_reactive_cache(instance, cached_value)
@@ -73,10 +93,12 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do
it { is_expected.to be_nil }
- it 'refreshes cache' do
- expect(ReactiveCachingWorker).to receive(:perform_async).with(CacheTest, 666)
+ it_behaves_like 'reactive worker call', ReactiveCachingWorker do
+ let(:test_class) { cache_class_test }
+ end
- instance.with_reactive_cache { raise described_class::InvalidateReactiveCache }
+ it_behaves_like 'reactive worker call', ExternalServiceReactiveCachingWorker do
+ let(:test_class) { external_dependency_cache_class_test }
end
end
end
@@ -84,10 +106,12 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do
context 'when cache is empty' do
it { is_expected.to be_nil }
- it 'enqueues a background worker to bootstrap the cache' do
- expect(ReactiveCachingWorker).to receive(:perform_async).with(CacheTest, 666)
+ it_behaves_like 'reactive worker call', ReactiveCachingWorker do
+ let(:test_class) { cache_class_test }
+ end
- go!
+ it_behaves_like 'reactive worker call', ExternalServiceReactiveCachingWorker do
+ let(:test_class) { external_dependency_cache_class_test }
end
it 'updates the cache lifespan' do
@@ -168,12 +192,14 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do
context 'with custom reactive_cache_worker_finder' do
let(:args) { %w(arg1 arg2) }
- let(:instance) { CustomFinderCacheTest.new(666, &calculation) }
+ let(:instance) { custom_finder_cache_test.new(666, &calculation) }
- class CustomFinderCacheTest < CacheTest
- self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
+ let(:custom_finder_cache_test) do
+ Class.new(cache_class_test) do
+ self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
- def self.from_cache(*args); end
+ def self.from_cache(*args); end
+ end
end
before do
@@ -234,6 +260,18 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do
go!
end
+ context 'when :external_dependency cache' do
+ let(:instance) do
+ external_dependency_cache_class_test.new(666, &calculation)
+ end
+
+ it 'enqueues a repeat worker' do
+ expect_reactive_cache_update_queued(instance, worker_klass: ExternalServiceReactiveCachingWorker)
+
+ go!
+ end
+ end
+
it 'calls a reactive_cache_updated only once if content did not change on subsequent update' do
expect(instance).to receive(:calculate_reactive_cache).twice
expect(instance).to receive(:reactive_cache_updated).once
@@ -262,7 +300,7 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do
it_behaves_like 'ExceededReactiveCacheLimit'
context 'when reactive_cache_hard_limit is overridden' do
- let(:test_class) { Class.new(CacheTest) { self.reactive_cache_hard_limit = 3.megabytes } }
+ let(:test_class) { Class.new(cache_class_test) { self.reactive_cache_hard_limit = 3.megabytes } }
let(:instance) { test_class.new(666, &calculation) }
it_behaves_like 'successful cache'
diff --git a/spec/models/concerns/redis_cacheable_spec.rb b/spec/models/concerns/redis_cacheable_spec.rb
index f88d64e2013..1cf6afcc167 100644
--- a/spec/models/concerns/redis_cacheable_spec.rb
+++ b/spec/models/concerns/redis_cacheable_spec.rb
@@ -31,7 +31,7 @@ describe RedisCacheable do
subject { instance.cached_attribute(payload.each_key.first) }
it 'gets the cache attribute' do
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
expect(redis).to receive(:get).with(cache_key)
.and_return(payload.to_json)
end
@@ -44,7 +44,7 @@ describe RedisCacheable do
subject { instance.cache_attributes(payload) }
it 'sets the cache attributes' do
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
expect(redis).to receive(:set).with(cache_key, payload.to_json, anything)
end
@@ -52,7 +52,7 @@ describe RedisCacheable do
end
end
- describe '#cached_attr_reader', :clean_gitlab_redis_shared_state do
+ describe '#cached_attr_reader', :clean_gitlab_redis_cache do
subject { instance.name }
before do
diff --git a/spec/models/concerns/spammable_spec.rb b/spec/models/concerns/spammable_spec.rb
index b8537dd39f6..a8d27e174b7 100644
--- a/spec/models/concerns/spammable_spec.rb
+++ b/spec/models/concerns/spammable_spec.rb
@@ -39,43 +39,100 @@ describe Spammable do
describe '#invalidate_if_spam' do
using RSpec::Parameterized::TableSyntax
+ before do
+ stub_application_setting(recaptcha_enabled: true)
+ end
+
context 'when the model is spam' do
- where(:recaptcha_enabled, :error) do
- true | /solve the reCAPTCHA to proceed/
- false | /has been discarded/
+ subject { invalidate_if_spam(is_spam: true) }
+
+ it 'has an error related to spam on the model' do
+ expect(subject.errors.messages[:base]).to match_array /has been discarded/
end
+ end
- with_them do
- subject { invalidate_if_spam(true, recaptcha_enabled) }
+ context 'when the model needs recaptcha' do
+ subject { invalidate_if_spam(needs_recaptcha: true) }
- it 'has an error related to spam on the model' do
- expect(subject.errors.messages[:base]).to match_array error
- end
+ it 'has an error related to spam on the model' do
+ expect(subject.errors.messages[:base]).to match_array /solve the reCAPTCHA/
end
end
- context 'when the model is not spam' do
- [true, false].each do |enabled|
- let(:recaptcha_enabled) { enabled }
+ context 'if the model is spam and also needs recaptcha' do
+ subject { invalidate_if_spam(is_spam: true, needs_recaptcha: true) }
+
+ it 'has an error related to spam on the model' do
+ expect(subject.errors.messages[:base]).to match_array /solve the reCAPTCHA/
+ end
+ end
- subject { invalidate_if_spam(false, recaptcha_enabled) }
+ context 'when the model is not spam nor needs recaptcha' do
+ subject { invalidate_if_spam }
- it 'returns no error' do
- expect(subject.errors.messages[:base]).to be_empty
- end
+ it 'returns no error' do
+ expect(subject.errors.messages[:base]).to be_empty
end
end
- def invalidate_if_spam(is_spam, recaptcha_enabled)
- stub_application_setting(recaptcha_enabled: recaptcha_enabled)
+ context 'if recaptcha is not enabled and the model needs recaptcha' do
+ before do
+ stub_application_setting(recaptcha_enabled: false)
+ end
+
+ subject { invalidate_if_spam(needs_recaptcha: true) }
+ it 'has no errors' do
+ expect(subject.errors.messages[:base]).to match_array /has been discarded/
+ end
+ end
+
+ def invalidate_if_spam(is_spam: false, needs_recaptcha: false)
issue.tap do |i|
i.spam = is_spam
+ i.needs_recaptcha = needs_recaptcha
i.invalidate_if_spam
end
end
end
+ describe 'spam flags' do
+ before do
+ issue.spam = false
+ issue.needs_recaptcha = false
+ end
+
+ describe '#spam!' do
+ it 'adds only `spam` flag' do
+ issue.spam!
+
+ expect(issue.spam).to be_truthy
+ expect(issue.needs_recaptcha).to be_falsey
+ end
+ end
+
+ describe '#needs_recaptcha!' do
+ it 'adds `needs_recaptcha` flag' do
+ issue.needs_recaptcha!
+
+ expect(issue.spam).to be_falsey
+ expect(issue.needs_recaptcha).to be_truthy
+ end
+ end
+
+ describe '#clear_spam_flags!' do
+ it 'clears spam and recaptcha flags' do
+ issue.spam = true
+ issue.needs_recaptcha = true
+
+ issue.clear_spam_flags!
+
+ expect(issue).not_to be_spam
+ expect(issue.needs_recaptcha).to be_falsey
+ end
+ end
+ end
+
describe '#submittable_as_spam_by?' do
let(:admin) { build(:admin) }
let(:user) { build(:user) }
diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb
index 5bcd9dfd396..1eecefe5d4a 100644
--- a/spec/models/container_repository_spec.rb
+++ b/spec/models/container_repository_spec.rb
@@ -19,7 +19,7 @@ describe ContainerRepository do
.with(headers: { 'Accept' => ContainerRegistry::Client::ACCEPTED_TYPES.join(', ') })
.to_return(
status: 200,
- body: JSON.dump(tags: ['test_tag']),
+ body: Gitlab::Json.dump(tags: ['test_tag']),
headers: { 'Content-Type' => 'application/json' })
end
@@ -309,4 +309,14 @@ describe ContainerRepository do
it { is_expected.to eq([]) }
end
end
+
+ describe '.search_by_name' do
+ let!(:another_repository) do
+ create(:container_repository, name: 'my_foo_bar', project: project)
+ end
+
+ subject { described_class.search_by_name('my_image') }
+
+ it { is_expected.to contain_exactly(repository) }
+ end
end
diff --git a/spec/models/cycle_analytics/code_spec.rb b/spec/models/cycle_analytics/code_spec.rb
index 441f8265629..f6ab8e0ece6 100644
--- a/spec/models/cycle_analytics/code_spec.rb
+++ b/spec/models/cycle_analytics/code_spec.rb
@@ -7,7 +7,7 @@ describe 'CycleAnalytics#code' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:from_date) { 10.days.ago }
- let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:user) { project.owner }
let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) }
subject { project_level }
diff --git a/spec/models/cycle_analytics/group_level_spec.rb b/spec/models/cycle_analytics/group_level_spec.rb
deleted file mode 100644
index ac169ebc0cf..00000000000
--- a/spec/models/cycle_analytics/group_level_spec.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe CycleAnalytics::GroupLevel do
- let_it_be(:group) { create(:group)}
- let_it_be(:project) { create(:project, :repository, namespace: group) }
- let_it_be(:from_date) { 10.days.ago }
- let_it_be(:user) { create(:user, :admin) }
- let(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
- let_it_be(:milestone) { create(:milestone, project: project) }
- let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") }
- let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) }
-
- subject { described_class.new(group: group, options: { from: from_date, current_user: user }) }
-
- describe '#permissions' do
- it 'returns true for all stages' do
- expect(subject.permissions.values.uniq).to eq([true])
- end
- end
-
- describe '#stats' do
- before do
- create_cycle(user, project, issue, mr, milestone, pipeline)
- deploy_master(user, project)
- end
-
- it 'returns medians for each stage for a specific group' do
- expect(subject.no_stats?).to eq(false)
- end
- end
-
- describe '#summary' do
- before do
- create_cycle(user, project, issue, mr, milestone, pipeline)
- deploy_master(user, project)
- end
-
- it 'returns medians for each stage for a specific group' do
- expect(subject.summary.map { |summary| summary[:value] }).to contain_exactly('0.1', '1', '1')
- end
- end
-end
diff --git a/spec/models/cycle_analytics/issue_spec.rb b/spec/models/cycle_analytics/issue_spec.rb
index 726f2f8b018..b4ab763e0e6 100644
--- a/spec/models/cycle_analytics/issue_spec.rb
+++ b/spec/models/cycle_analytics/issue_spec.rb
@@ -7,7 +7,7 @@ describe 'CycleAnalytics#issue' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:from_date) { 10.days.ago }
- let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:user) { project.owner }
let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) }
subject { project_level }
diff --git a/spec/models/cycle_analytics/plan_spec.rb b/spec/models/cycle_analytics/plan_spec.rb
index 3bd9f317ca7..6765b2e2cbc 100644
--- a/spec/models/cycle_analytics/plan_spec.rb
+++ b/spec/models/cycle_analytics/plan_spec.rb
@@ -7,7 +7,7 @@ describe 'CycleAnalytics#plan' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:from_date) { 10.days.ago }
- let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:user) { project.owner }
let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) }
subject { project_level }
diff --git a/spec/models/cycle_analytics/production_spec.rb b/spec/models/cycle_analytics/production_spec.rb
index 01d88bbeec9..2f2bcd63acd 100644
--- a/spec/models/cycle_analytics/production_spec.rb
+++ b/spec/models/cycle_analytics/production_spec.rb
@@ -7,7 +7,7 @@ describe 'CycleAnalytics#production' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:from_date) { 10.days.ago }
- let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:user) { project.owner }
let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) }
subject { project_level }
diff --git a/spec/models/cycle_analytics/project_level_spec.rb b/spec/models/cycle_analytics/project_level_spec.rb
index 2fc81777746..bb296351a29 100644
--- a/spec/models/cycle_analytics/project_level_spec.rb
+++ b/spec/models/cycle_analytics/project_level_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
describe CycleAnalytics::ProjectLevel do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:from_date) { 10.days.ago }
- let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:user) { project.owner }
let_it_be(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
let_it_be(:milestone) { create(:milestone, project: project) }
let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") }
diff --git a/spec/models/cycle_analytics/review_spec.rb b/spec/models/cycle_analytics/review_spec.rb
index 50670188e85..25e8f1441d3 100644
--- a/spec/models/cycle_analytics/review_spec.rb
+++ b/spec/models/cycle_analytics/review_spec.rb
@@ -7,7 +7,7 @@ describe 'CycleAnalytics#review' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:from_date) { 10.days.ago }
- let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:user) { project.owner }
subject { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) }
diff --git a/spec/models/cycle_analytics/staging_spec.rb b/spec/models/cycle_analytics/staging_spec.rb
index cf0695f175a..effbc7056cc 100644
--- a/spec/models/cycle_analytics/staging_spec.rb
+++ b/spec/models/cycle_analytics/staging_spec.rb
@@ -7,7 +7,7 @@ describe 'CycleAnalytics#staging' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:from_date) { 10.days.ago }
- let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:user) { project.owner }
let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) }
subject { project_level }
diff --git a/spec/models/cycle_analytics/test_spec.rb b/spec/models/cycle_analytics/test_spec.rb
index 24800aafca7..7e7ba4d9994 100644
--- a/spec/models/cycle_analytics/test_spec.rb
+++ b/spec/models/cycle_analytics/test_spec.rb
@@ -7,7 +7,7 @@ describe 'CycleAnalytics#test' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:from_date) { 10.days.ago }
- let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:user) { project.owner }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) }
let!(:merge_request) { create_merge_request_closing_issue(user, project, issue) }
diff --git a/spec/models/deploy_token_spec.rb b/spec/models/deploy_token_spec.rb
index a2d4c046d46..819e2850644 100644
--- a/spec/models/deploy_token_spec.rb
+++ b/spec/models/deploy_token_spec.rb
@@ -72,8 +72,10 @@ describe DeployToken do
describe '#scopes' do
context 'with all the scopes' do
+ let_it_be(:deploy_token) { create(:deploy_token, :all_scopes) }
+
it 'returns scopes assigned to DeployToken' do
- expect(deploy_token.scopes).to eq([:read_repository, :read_registry])
+ expect(deploy_token.scopes).to eq(DeployToken::AVAILABLE_SCOPES)
end
end
diff --git a/spec/models/design_management/action_spec.rb b/spec/models/design_management/action_spec.rb
new file mode 100644
index 00000000000..753c31b1549
--- /dev/null
+++ b/spec/models/design_management/action_spec.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe DesignManagement::Action do
+ describe 'relations' do
+ it { is_expected.to belong_to(:design) }
+ it { is_expected.to belong_to(:version) }
+ end
+
+ describe 'scopes' do
+ describe '.most_recent' do
+ let_it_be(:design_a) { create(:design) }
+ let_it_be(:design_b) { create(:design) }
+ let_it_be(:design_c) { create(:design) }
+
+ let(:designs) { [design_a, design_b, design_c] }
+
+ before_all do
+ create(:design_version, designs: [design_a, design_b, design_c])
+ create(:design_version, designs: [design_a, design_b])
+ create(:design_version, designs: [design_a])
+ end
+
+ it 'finds the correct version for each design' do
+ dvs = described_class.where(design: designs)
+
+ expected = designs
+ .map(&:id)
+ .zip(dvs.order("version_id DESC").pluck(:version_id).uniq)
+
+ actual = dvs.most_recent.map { |dv| [dv.design_id, dv.version_id] }
+
+ expect(actual).to eq(expected)
+ end
+ end
+
+ describe '.up_to_version' do
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:design_a) { create(:design, issue: issue) }
+ let_it_be(:design_b) { create(:design, issue: issue) }
+
+ # let bindings are not available in before(:all) contexts,
+ # so we need to redefine the array on each construction.
+ let_it_be(:oldest) { create(:design_version, designs: [design_a, design_b]) }
+ let_it_be(:middle) { create(:design_version, designs: [design_a, design_b]) }
+ let_it_be(:newest) { create(:design_version, designs: [design_a, design_b]) }
+
+ subject { described_class.where(design: issue.designs).up_to_version(version) }
+
+ context 'the version is nil' do
+ let(:version) { nil }
+
+ it 'returns all design_versions' do
+ is_expected.to have_attributes(size: 6)
+ end
+ end
+
+ context 'when given a Version instance' do
+ context 'the version is the most current' do
+ let(:version) { newest }
+
+ it { is_expected.to have_attributes(size: 6) }
+ end
+
+ context 'the version is the oldest' do
+ let(:version) { oldest }
+
+ it { is_expected.to have_attributes(size: 2) }
+ end
+
+ context 'the version is the middle one' do
+ let(:version) { middle }
+
+ it { is_expected.to have_attributes(size: 4) }
+ end
+ end
+
+ context 'when given a commit SHA' do
+ context 'the version is the most current' do
+ let(:version) { newest.sha }
+
+ it { is_expected.to have_attributes(size: 6) }
+ end
+
+ context 'the version is the oldest' do
+ let(:version) { oldest.sha }
+
+ it { is_expected.to have_attributes(size: 2) }
+ end
+
+ context 'the version is the middle one' do
+ let(:version) { middle.sha }
+
+ it { is_expected.to have_attributes(size: 4) }
+ end
+ end
+
+ context 'when given a String that is not a commit SHA' do
+ let(:version) { 'foo' }
+
+ it { expect { subject }.to raise_error(ArgumentError) }
+ end
+ end
+ end
+end
diff --git a/spec/models/design_management/design_action_spec.rb b/spec/models/design_management/design_action_spec.rb
new file mode 100644
index 00000000000..da4ad41dfcb
--- /dev/null
+++ b/spec/models/design_management/design_action_spec.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe DesignManagement::DesignAction do
+ describe 'validations' do
+ describe 'the design' do
+ let(:fail_validation) { raise_error(/design/i) }
+
+ it 'must not be nil' do
+ expect { described_class.new(nil, :create, :foo) }.to fail_validation
+ end
+ end
+
+ describe 'the action' do
+ let(:fail_validation) { raise_error(/action/i) }
+
+ it 'must not be nil' do
+ expect { described_class.new(double, nil, :foo) }.to fail_validation
+ end
+
+ it 'must be a known action' do
+ expect { described_class.new(double, :wibble, :foo) }.to fail_validation
+ end
+ end
+
+ describe 'the content' do
+ context 'content is necesary' do
+ let(:fail_validation) { raise_error(/needs content/i) }
+
+ %i[create update].each do |action|
+ it "must not be nil if the action is #{action}" do
+ expect { described_class.new(double, action, nil) }.to fail_validation
+ end
+ end
+ end
+
+ context 'content is forbidden' do
+ let(:fail_validation) { raise_error(/forbids content/i) }
+
+ it "must not be nil if the action is delete" do
+ expect { described_class.new(double, :delete, :foo) }.to fail_validation
+ end
+ end
+ end
+ end
+
+ describe '#gitaly_action' do
+ let(:path) { 'some/path/somewhere' }
+ let(:design) { OpenStruct.new(full_path: path) }
+
+ subject { described_class.new(design, action, content) }
+
+ context 'the action needs content' do
+ let(:action) { :create }
+ let(:content) { :foo }
+
+ it 'produces a good gitaly action' do
+ expect(subject.gitaly_action).to eq(
+ action: action,
+ file_path: path,
+ content: content
+ )
+ end
+ end
+
+ context 'the action forbids content' do
+ let(:action) { :delete }
+ let(:content) { nil }
+
+ it 'produces a good gitaly action' do
+ expect(subject.gitaly_action).to eq(action: action, file_path: path)
+ end
+ end
+ end
+
+ describe '#issue_id' do
+ let(:issue_id) { :foo }
+ let(:design) { OpenStruct.new(issue_id: issue_id) }
+
+ subject { described_class.new(design, :delete) }
+
+ it 'delegates to the design' do
+ expect(subject.issue_id).to eq(issue_id)
+ end
+ end
+
+ describe '#performed' do
+ let(:design) { double }
+
+ subject { described_class.new(design, :delete) }
+
+ it 'calls design#clear_version_cache when the action has been performed' do
+ expect(design).to receive(:clear_version_cache)
+
+ subject.performed
+ end
+ end
+end
diff --git a/spec/models/design_management/design_at_version_spec.rb b/spec/models/design_management/design_at_version_spec.rb
new file mode 100644
index 00000000000..f6fa8df243c
--- /dev/null
+++ b/spec/models/design_management/design_at_version_spec.rb
@@ -0,0 +1,426 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::DesignAtVersion do
+ include DesignManagementTestHelpers
+
+ let_it_be(:issue, reload: true) { create(:issue) }
+ let_it_be(:issue_b, reload: true) { create(:issue) }
+ let_it_be(:design, reload: true) { create(:design, issue: issue) }
+ let_it_be(:version) { create(:design_version, designs: [design]) }
+
+ describe '#id' do
+ subject { described_class.new(design: design, version: version) }
+
+ it 'combines design.id and version.id' do
+ expect(subject.id).to include(design.id.to_s, version.id.to_s)
+ end
+ end
+
+ describe '#==' do
+ it 'identifies objects created with the same parameters as equal' do
+ design = build_stubbed(:design, issue: issue)
+ version = build_stubbed(:design_version, designs: [design], issue: issue)
+
+ this = build_stubbed(:design_at_version, design: design, version: version)
+ other = build_stubbed(:design_at_version, design: design, version: version)
+
+ expect(this).to eq(other)
+ expect(other).to eq(this)
+ end
+
+ it 'identifies unequal objects as unequal, by virtue of their version' do
+ design = build_stubbed(:design, issue: issue)
+ version_a = build_stubbed(:design_version, designs: [design])
+ version_b = build_stubbed(:design_version, designs: [design])
+
+ this = build_stubbed(:design_at_version, design: design, version: version_a)
+ other = build_stubbed(:design_at_version, design: design, version: version_b)
+
+ expect(this).not_to eq(nil)
+ expect(this).not_to eq(design)
+ expect(this).not_to eq(other)
+ expect(other).not_to eq(this)
+ end
+
+ it 'identifies unequal objects as unequal, by virtue of their design' do
+ design_a = build_stubbed(:design, issue: issue)
+ design_b = build_stubbed(:design, issue: issue)
+ version = build_stubbed(:design_version, designs: [design_a, design_b])
+
+ this = build_stubbed(:design_at_version, design: design_a, version: version)
+ other = build_stubbed(:design_at_version, design: design_b, version: version)
+
+ expect(this).not_to eq(other)
+ expect(other).not_to eq(this)
+ end
+
+ it 'rejects objects with the same id and the wrong class' do
+ dav = build_stubbed(:design_at_version)
+
+ expect(dav).not_to eq(OpenStruct.new(id: dav.id))
+ end
+
+ it 'expects objects to be of the same type, not subtypes' do
+ subtype = Class.new(described_class)
+ dav = build_stubbed(:design_at_version)
+ other = subtype.new(design: dav.design, version: dav.version)
+
+ expect(dav).not_to eq(other)
+ end
+ end
+
+ describe 'status methods' do
+ let!(:design_a) { create(:design, issue: issue) }
+ let!(:design_b) { create(:design, issue: issue) }
+
+ let!(:version_a) do
+ create(:design_version, designs: [design_a])
+ end
+ let!(:version_b) do
+ create(:design_version, designs: [design_b])
+ end
+ let!(:version_mod) do
+ create(:design_version, modified_designs: [design_a, design_b])
+ end
+ let!(:version_c) do
+ create(:design_version, deleted_designs: [design_a])
+ end
+ let!(:version_d) do
+ create(:design_version, deleted_designs: [design_b])
+ end
+ let!(:version_e) do
+ create(:design_version, designs: [design_a])
+ end
+
+ describe 'a design before it has been created' do
+ subject { build(:design_at_version, design: design_b, version: version_a) }
+
+ it 'is not deleted' do
+ expect(subject).not_to be_deleted
+ end
+
+ it 'has the status :not_created_yet' do
+ expect(subject).to have_attributes(status: :not_created_yet)
+ end
+ end
+
+ describe 'a design as of its creation' do
+ subject { build(:design_at_version, design: design_a, version: version_a) }
+
+ it 'is not deleted' do
+ expect(subject).not_to be_deleted
+ end
+
+ it 'has the status :current' do
+ expect(subject).to have_attributes(status: :current)
+ end
+ end
+
+ describe 'a design after it has been created, but before deletion' do
+ subject { build(:design_at_version, design: design_b, version: version_c) }
+
+ it 'is not deleted' do
+ expect(subject).not_to be_deleted
+ end
+
+ it 'has the status :current' do
+ expect(subject).to have_attributes(status: :current)
+ end
+ end
+
+ describe 'a design as of its modification' do
+ subject { build(:design_at_version, design: design_a, version: version_mod) }
+
+ it 'is not deleted' do
+ expect(subject).not_to be_deleted
+ end
+
+ it 'has the status :current' do
+ expect(subject).to have_attributes(status: :current)
+ end
+ end
+
+ describe 'a design as of its deletion' do
+ subject { build(:design_at_version, design: design_a, version: version_c) }
+
+ it 'is deleted' do
+ expect(subject).to be_deleted
+ end
+
+ it 'has the status :deleted' do
+ expect(subject).to have_attributes(status: :deleted)
+ end
+ end
+
+ describe 'a design after its deletion' do
+ subject { build(:design_at_version, design: design_b, version: version_e) }
+
+ it 'is deleted' do
+ expect(subject).to be_deleted
+ end
+
+ it 'has the status :deleted' do
+ expect(subject).to have_attributes(status: :deleted)
+ end
+ end
+
+ describe 'a design on its recreation' do
+ subject { build(:design_at_version, design: design_a, version: version_e) }
+
+ it 'is not deleted' do
+ expect(subject).not_to be_deleted
+ end
+
+ it 'has the status :current' do
+ expect(subject).to have_attributes(status: :current)
+ end
+ end
+ end
+
+ describe 'validations' do
+ subject(:design_at_version) { build(:design_at_version) }
+
+ it { is_expected.to be_valid }
+
+ describe 'a design-at-version without a design' do
+ subject { described_class.new(design: nil, version: build(:design_version)) }
+
+ it { is_expected.to be_invalid }
+
+ it 'mentions the design in the errors' do
+ subject.valid?
+
+ expect(subject.errors[:design]).to be_present
+ end
+ end
+
+ describe 'a design-at-version without a version' do
+ subject { described_class.new(design: build(:design), version: nil) }
+
+ it { is_expected.to be_invalid }
+
+ it 'mentions the version in the errors' do
+ subject.valid?
+
+ expect(subject.errors[:version]).to be_present
+ end
+ end
+
+ describe 'design_and_version_belong_to_the_same_issue' do
+ context 'both design and version are supplied' do
+ subject(:design_at_version) { build(:design_at_version, design: design, version: version) }
+
+ context 'the design belongs to the same issue as the version' do
+ it { is_expected.to be_valid }
+ end
+
+ context 'the design does not belong to the same issue as the version' do
+ let(:design) { create(:design) }
+ let(:version) { create(:design_version) }
+
+ it { is_expected.to be_invalid }
+ end
+ end
+
+ context 'the factory is just supplied with a design' do
+ let(:design) { create(:design) }
+
+ subject(:design_at_version) { build(:design_at_version, design: design) }
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'the factory is just supplied with a version' do
+ let(:version) { create(:design_version) }
+
+ subject(:design_at_version) { build(:design_at_version, version: version) }
+
+ it { is_expected.to be_valid }
+ end
+ end
+
+ describe 'design_and_version_have_issue_id' do
+ subject(:design_at_version) { build(:design_at_version, design: design, version: version) }
+
+ context 'the design has no issue_id, because it is being imported' do
+ let(:design) { create(:design, :importing) }
+
+ it { is_expected.to be_invalid }
+ end
+
+ context 'the version has no issue_id, because it is being imported' do
+ let(:version) { create(:design_version, :importing) }
+
+ it { is_expected.to be_invalid }
+ end
+
+ context 'both the design and the version are being imported' do
+ let(:version) { create(:design_version, :importing) }
+ let(:design) { create(:design, :importing) }
+
+ it { is_expected.to be_invalid }
+ end
+ end
+ end
+
+ def id_of(design, version)
+ build(:design_at_version, design: design, version: version).id
+ end
+
+ describe '.instantiate' do
+ context 'when attrs are valid' do
+ subject do
+ described_class.instantiate(design: design, version: version)
+ end
+
+ it { is_expected.to be_a(described_class).and(be_valid) }
+ end
+
+ context 'when attrs are invalid' do
+ subject do
+ described_class.instantiate(
+ design: create(:design),
+ version: create(:design_version)
+ )
+ end
+
+ it 'raises a validation error' do
+ expect { subject }.to raise_error(ActiveModel::ValidationError)
+ end
+ end
+ end
+
+ describe '.lazy_find' do
+ let!(:version_a) do
+ create(:design_version, designs: create_list(:design, 3, issue: issue))
+ end
+ let!(:version_b) do
+ create(:design_version, designs: create_list(:design, 1, issue: issue))
+ end
+ let!(:version_c) do
+ create(:design_version, designs: create_list(:design, 1, issue: issue_b))
+ end
+
+ let(:id_a) { id_of(version_a.designs.first, version_a) }
+ let(:id_b) { id_of(version_a.designs.second, version_a) }
+ let(:id_c) { id_of(version_a.designs.last, version_a) }
+ let(:id_d) { id_of(version_b.designs.first, version_b) }
+ let(:id_e) { id_of(version_c.designs.first, version_c) }
+ let(:bad_id) { id_of(version_c.designs.first, version_a) }
+
+ def find(the_id)
+ described_class.lazy_find(the_id)
+ end
+
+ let(:db_calls) { 2 }
+
+ it 'issues fewer queries than the naive approach would' do
+ expect do
+ dav_a = find(id_a)
+ dav_b = find(id_b)
+ dav_c = find(id_c)
+ dav_d = find(id_d)
+ dav_e = find(id_e)
+ should_not_exist = find(bad_id)
+
+ expect(dav_a.version).to eq(version_a)
+ expect(dav_b.version).to eq(version_a)
+ expect(dav_c.version).to eq(version_a)
+ expect(dav_d.version).to eq(version_b)
+ expect(dav_e.version).to eq(version_c)
+ expect(should_not_exist).not_to be_present
+
+ expect(version_a.designs).to include(dav_a.design, dav_b.design, dav_c.design)
+ expect(version_b.designs).to include(dav_d.design)
+ expect(version_c.designs).to include(dav_e.design)
+ end.not_to exceed_query_limit(db_calls)
+ end
+ end
+
+ describe '.find' do
+ let(:results) { described_class.find(ids) }
+
+ # 2 versions, with 5 total designs on issue A, so 2*5 = 10
+ let!(:version_a) do
+ create(:design_version, designs: create_list(:design, 3, issue: issue))
+ end
+ let!(:version_b) do
+ create(:design_version, designs: create_list(:design, 2, issue: issue))
+ end
+ # 1 version, with 3 designs on issue B, so 1*3 = 3
+ let!(:version_c) do
+ create(:design_version, designs: create_list(:design, 3, issue: issue_b))
+ end
+
+ context 'invalid ids' do
+ let(:ids) do
+ version_b.designs.map { |d| id_of(d, version_c) }
+ end
+
+ describe '#count' do
+ it 'counts 0 records' do
+ expect(results.count).to eq(0)
+ end
+ end
+
+ describe '#empty?' do
+ it 'is empty' do
+ expect(results).to be_empty
+ end
+ end
+
+ describe '#to_a' do
+ it 'finds no records' do
+ expect(results.to_a).to eq([])
+ end
+ end
+ end
+
+ context 'valid ids' do
+ let(:red_herrings) { issue_b.designs.sample(2).map { |d| id_of(d, version_a) } }
+
+ let(:ids) do
+ a_ids = issue.designs.sample(2).map { |d| id_of(d, version_a) }
+ b_ids = issue.designs.sample(2).map { |d| id_of(d, version_b) }
+ c_ids = issue_b.designs.sample(2).map { |d| id_of(d, version_c) }
+
+ a_ids + b_ids + c_ids + red_herrings
+ end
+
+ before do
+ ids.size # force IDs
+ end
+
+ describe '#count' do
+ it 'counts 2 records' do
+ expect(results.count).to eq(6)
+ end
+
+ it 'issues at most two queries' do
+ expect { results.count }.not_to exceed_query_limit(2)
+ end
+ end
+
+ describe '#to_a' do
+ it 'finds 6 records' do
+ expect(results.size).to eq(6)
+ expect(results).to all(be_a(described_class))
+ end
+
+ it 'only returns records with matching IDs' do
+ expect(results.map(&:id)).to match_array(ids - red_herrings)
+ end
+
+ it 'only returns valid records' do
+ expect(results).to all(be_valid)
+ end
+
+ it 'issues at most two queries' do
+ expect { results.to_a }.not_to exceed_query_limit(2)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/design_management/design_collection_spec.rb b/spec/models/design_management/design_collection_spec.rb
new file mode 100644
index 00000000000..bd48f742042
--- /dev/null
+++ b/spec/models/design_management/design_collection_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe DesignManagement::DesignCollection do
+ include DesignManagementTestHelpers
+
+ let_it_be(:issue, reload: true) { create(:issue) }
+
+ subject(:collection) { described_class.new(issue) }
+
+ describe ".find_or_create_design!" do
+ it "finds an existing design" do
+ design = create(:design, issue: issue, filename: 'world.png')
+
+ expect(collection.find_or_create_design!(filename: 'world.png')).to eq(design)
+ end
+
+ it "creates a new design if one didn't exist" do
+ expect(issue.designs.size).to eq(0)
+
+ new_design = collection.find_or_create_design!(filename: 'world.png')
+
+ expect(issue.designs.size).to eq(1)
+ expect(new_design.filename).to eq('world.png')
+ expect(new_design.issue).to eq(issue)
+ end
+
+ it "only queries the designs once" do
+ create(:design, issue: issue, filename: 'hello.png')
+ create(:design, issue: issue, filename: 'world.jpg')
+
+ expect do
+ collection.find_or_create_design!(filename: 'hello.png')
+ collection.find_or_create_design!(filename: 'world.jpg')
+ end.not_to exceed_query_limit(1)
+ end
+ end
+
+ describe "#versions" do
+ it "includes versions for all designs" do
+ version_1 = create(:design_version)
+ version_2 = create(:design_version)
+ other_version = create(:design_version)
+ create(:design, issue: issue, versions: [version_1])
+ create(:design, issue: issue, versions: [version_2])
+ create(:design, versions: [other_version])
+
+ expect(collection.versions).to contain_exactly(version_1, version_2)
+ end
+ end
+
+ describe "#repository" do
+ it "builds a design repository" do
+ expect(collection.repository).to be_a(DesignManagement::Repository)
+ end
+ end
+
+ describe '#designs_by_filename' do
+ let(:designs) { create_list(:design, 5, :with_file, issue: issue) }
+ let(:filenames) { designs.map(&:filename) }
+ let(:query) { subject.designs_by_filename(filenames) }
+
+ it 'finds all the designs with those filenames on this issue' do
+ expect(query).to have_attributes(size: 5)
+ end
+
+ it 'only makes a single query' do
+ designs.each(&:id)
+ expect { query }.not_to exceed_query_limit(1)
+ end
+
+ context 'some are deleted' do
+ before do
+ delete_designs(*designs.sample(2))
+ end
+
+ it 'takes deletion into account' do
+ expect(query).to have_attributes(size: 3)
+ end
+ end
+ end
+end
diff --git a/spec/models/design_management/design_spec.rb b/spec/models/design_management/design_spec.rb
new file mode 100644
index 00000000000..95782c1f674
--- /dev/null
+++ b/spec/models/design_management/design_spec.rb
@@ -0,0 +1,575 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::Design do
+ include DesignManagementTestHelpers
+
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:design1) { create(:design, :with_versions, issue: issue, versions_count: 1) }
+ let_it_be(:design2) { create(:design, :with_versions, issue: issue, versions_count: 1) }
+ let_it_be(:design3) { create(:design, :with_versions, issue: issue, versions_count: 1) }
+ let_it_be(:deleted_design) { create(:design, :with_versions, deleted: true) }
+
+ describe 'relations' do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:issue) }
+ it { is_expected.to have_many(:actions) }
+ it { is_expected.to have_many(:versions) }
+ it { is_expected.to have_many(:notes).dependent(:delete_all) }
+ it { is_expected.to have_many(:user_mentions) }
+ end
+
+ describe 'validations' do
+ subject(:design) { build(:design) }
+
+ it { is_expected.to be_valid }
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_presence_of(:issue) }
+ it { is_expected.to validate_presence_of(:filename) }
+ it { is_expected.to validate_uniqueness_of(:filename).scoped_to(:issue_id) }
+
+ it "validates that the extension is an image" do
+ design.filename = "thing.txt"
+ extensions = described_class::SAFE_IMAGE_EXT + described_class::DANGEROUS_IMAGE_EXT
+
+ expect(design).not_to be_valid
+ expect(design.errors[:filename].first).to eq(
+ "does not have a supported extension. Only #{extensions.to_sentence} are supported"
+ )
+ end
+
+ describe 'validating files with .svg extension' do
+ before do
+ design.filename = "thing.svg"
+ end
+
+ it "allows .svg files when feature flag is enabled" do
+ stub_feature_flags(design_management_allow_dangerous_images: true)
+
+ expect(design).to be_valid
+ end
+
+ it "does not allow .svg files when feature flag is disabled" do
+ stub_feature_flags(design_management_allow_dangerous_images: false)
+
+ expect(design).not_to be_valid
+ expect(design.errors[:filename].first).to eq(
+ "does not have a supported extension. Only #{described_class::SAFE_IMAGE_EXT.to_sentence} are supported"
+ )
+ end
+ end
+ end
+
+ describe 'scopes' do
+ describe '.visible_at_version' do
+ let(:versions) { DesignManagement::Version.where(issue: issue).ordered }
+ let(:found) { described_class.visible_at_version(version) }
+
+ context 'at oldest version' do
+ let(:version) { versions.last }
+
+ it 'finds the first design only' do
+ expect(found).to contain_exactly(design1)
+ end
+ end
+
+ context 'at version 2' do
+ let(:version) { versions.second }
+
+ it 'finds the first and second designs' do
+ expect(found).to contain_exactly(design1, design2)
+ end
+ end
+
+ context 'at latest version' do
+ let(:version) { versions.first }
+
+ it 'finds designs' do
+ expect(found).to contain_exactly(design1, design2, design3)
+ end
+ end
+
+ context 'when the argument is nil' do
+ let(:version) { nil }
+
+ it 'finds all undeleted designs' do
+ expect(found).to contain_exactly(design1, design2, design3)
+ end
+ end
+
+ describe 'one of the designs was deleted before the given version' do
+ before do
+ delete_designs(design2)
+ end
+
+ it 'is not returned' do
+ current_version = versions.first
+
+ expect(described_class.visible_at_version(current_version)).to contain_exactly(design1, design3)
+ end
+ end
+
+ context 'a re-created history' do
+ before do
+ delete_designs(design1, design2)
+ restore_designs(design1)
+ end
+
+ it 'is returned, though other deleted events are not' do
+ expect(described_class.visible_at_version(nil)).to contain_exactly(design1, design3)
+ end
+ end
+
+ # test that a design that has been modified at various points
+ # can be queried for correctly at different points in its history
+ describe 'dead or alive' do
+ let(:versions) { DesignManagement::Version.where(issue: issue).map { |v| [v, :alive] } }
+
+ before do
+ versions << [delete_designs(design1), :dead]
+ versions << [modify_designs(design2), :dead]
+ versions << [restore_designs(design1), :alive]
+ versions << [modify_designs(design3), :alive]
+ versions << [delete_designs(design1), :dead]
+ versions << [modify_designs(design2, design3), :dead]
+ versions << [restore_designs(design1), :alive]
+ end
+
+ it 'can establish the history at any point' do
+ history = versions.map(&:first).map do |v|
+ described_class.visible_at_version(v).include?(design1) ? :alive : :dead
+ end
+
+ expect(history).to eq(versions.map(&:second))
+ end
+ end
+ end
+
+ describe '.with_filename' do
+ it 'returns correct design when passed a single filename' do
+ expect(described_class.with_filename(design1.filename)).to eq([design1])
+ end
+
+ it 'returns correct designs when passed an Array of filenames' do
+ expect(
+ described_class.with_filename([design1, design2].map(&:filename))
+ ).to contain_exactly(design1, design2)
+ end
+ end
+
+ describe '.on_issue' do
+ it 'returns correct designs when passed a single issue' do
+ expect(described_class.on_issue(issue)).to match_array(issue.designs)
+ end
+
+ it 'returns correct designs when passed an Array of issues' do
+ expect(
+ described_class.on_issue([issue, deleted_design.issue])
+ ).to contain_exactly(design1, design2, design3, deleted_design)
+ end
+ end
+
+ describe '.current' do
+ it 'returns just the undeleted designs' do
+ delete_designs(design3)
+
+ expect(described_class.current).to contain_exactly(design1, design2)
+ end
+ end
+ end
+
+ describe '#visible_in?' do
+ let_it_be(:issue) { create(:issue) }
+
+ # It is expensive to re-create complex histories, so we do it once, and then
+ # assert that we can establish visibility at any given version.
+ it 'tells us when a design is visible' do
+ expected = []
+
+ first_design = create(:design, :with_versions, issue: issue, versions_count: 1)
+ prior_to_creation = first_design.versions.first
+ expected << [prior_to_creation, :not_created_yet, false]
+
+ v = modify_designs(first_design)
+ expected << [v, :not_created_yet, false]
+
+ design = create(:design, :with_versions, issue: issue, versions_count: 1)
+ created_in = design.versions.first
+ expected << [created_in, :created, true]
+
+ # The future state should not affect the result for any state, so we
+ # ensure that most states have a long future as well as a rich past
+ 2.times do
+ v = modify_designs(first_design)
+ expected << [v, :unaffected_visible, true]
+
+ v = modify_designs(design)
+ expected << [v, :modified, true]
+
+ v = modify_designs(first_design)
+ expected << [v, :unaffected_visible, true]
+
+ v = delete_designs(design)
+ expected << [v, :deleted, false]
+
+ v = modify_designs(first_design)
+ expected << [v, :unaffected_nv, false]
+
+ v = restore_designs(design)
+ expected << [v, :restored, true]
+ end
+
+ delete_designs(design) # ensure visibility is not corelated with current state
+
+ got = expected.map do |(v, sym, _)|
+ [v, sym, design.visible_in?(v)]
+ end
+
+ expect(got).to eq(expected)
+ end
+ end
+
+ describe '#to_ability_name' do
+ it { expect(described_class.new.to_ability_name).to eq('design') }
+ end
+
+ describe '#status' do
+ context 'the design is new' do
+ subject { build(:design) }
+
+ it { is_expected.to have_attributes(status: :new) }
+ end
+
+ context 'the design is current' do
+ subject { design1 }
+
+ it { is_expected.to have_attributes(status: :current) }
+ end
+
+ context 'the design has been deleted' do
+ subject { deleted_design }
+
+ it { is_expected.to have_attributes(status: :deleted) }
+ end
+ end
+
+ describe '#deleted?' do
+ context 'the design is new' do
+ let(:design) { build(:design) }
+
+ it 'is falsy' do
+ expect(design).not_to be_deleted
+ end
+ end
+
+ context 'the design is current' do
+ let(:design) { design1 }
+
+ it 'is falsy' do
+ expect(design).not_to be_deleted
+ end
+ end
+
+ context 'the design has been deleted' do
+ let(:design) { deleted_design }
+
+ it 'is truthy' do
+ expect(design).to be_deleted
+ end
+ end
+
+ context 'the design has been deleted, but was then re-created' do
+ let(:design) { create(:design, :with_versions, versions_count: 1, deleted: true) }
+
+ it 'is falsy' do
+ restore_designs(design)
+
+ expect(design).not_to be_deleted
+ end
+ end
+ end
+
+ describe "#new_design?" do
+ let(:design) { design1 }
+
+ it "is false when there are versions" do
+ expect(design1).not_to be_new_design
+ end
+
+ it "is true when there are no versions" do
+ expect(build(:design)).to be_new_design
+ end
+
+ it 'is false for deleted designs' do
+ expect(deleted_design).not_to be_new_design
+ end
+
+ it "does not cause extra queries when actions are loaded" do
+ design.actions.map(&:id)
+
+ expect { design.new_design? }.not_to exceed_query_limit(0)
+ end
+
+ it "implicitly caches values" do
+ expect do
+ design.new_design?
+ design.new_design?
+ end.not_to exceed_query_limit(1)
+ end
+
+ it "queries again when the clear_version_cache trigger has been called" do
+ expect do
+ design.new_design?
+ design.clear_version_cache
+ design.new_design?
+ end.not_to exceed_query_limit(2)
+ end
+
+ it "causes a single query when there versions are not loaded" do
+ design.reload
+
+ expect { design.new_design? }.not_to exceed_query_limit(1)
+ end
+ end
+
+ describe "#full_path" do
+ it "builds the full path for a design" do
+ design = build(:design, filename: "hello.jpg")
+ expected_path = "#{DesignManagement.designs_directory}/issue-#{design.issue.iid}/hello.jpg"
+
+ expect(design.full_path).to eq(expected_path)
+ end
+ end
+
+ describe '#diff_refs' do
+ let(:design) { create(:design, :with_file, versions_count: versions_count) }
+
+ context 'there are several versions' do
+ let(:versions_count) { 3 }
+
+ it "builds diff refs based on the first commit and it's for the design" do
+ expect(design.diff_refs.base_sha).to eq(design.versions.ordered.second.sha)
+ expect(design.diff_refs.head_sha).to eq(design.versions.ordered.first.sha)
+ end
+ end
+
+ context 'there is just one version' do
+ let(:versions_count) { 1 }
+
+ it 'builds diff refs based on the empty tree if there was only one version' do
+ design = create(:design, :with_file, versions_count: 1)
+
+ expect(design.diff_refs.base_sha).to eq(Gitlab::Git::BLANK_SHA)
+ expect(design.diff_refs.head_sha).to eq(design.diff_refs.head_sha)
+ end
+ end
+
+ it 'has no diff ref if new' do
+ design = build(:design)
+
+ expect(design.diff_refs).to be_nil
+ end
+ end
+
+ describe '#repository' do
+ it 'is a design repository' do
+ design = build(:design)
+
+ expect(design.repository).to be_a(DesignManagement::Repository)
+ end
+ end
+
+ describe '#note_etag_key' do
+ it 'returns a correct etag key' do
+ design = create(:design)
+
+ expect(design.note_etag_key).to eq(
+ ::Gitlab::Routing.url_helpers.designs_project_issue_path(design.project, design.issue, { vueroute: design.filename })
+ )
+ end
+ end
+
+ describe '#user_notes_count', :use_clean_rails_memory_store_caching do
+ let_it_be(:design) { create(:design, :with_file) }
+
+ subject { design.user_notes_count }
+
+ # Note: Cache invalidation tests are in `design_user_notes_count_service_spec.rb`
+
+ it 'returns a count of user-generated notes' do
+ create(:diff_note_on_design, noteable: design)
+
+ is_expected.to eq(1)
+ end
+
+ it 'does not count notes on other designs' do
+ second_design = create(:design, :with_file)
+ create(:diff_note_on_design, noteable: second_design)
+
+ is_expected.to eq(0)
+ end
+
+ it 'does not count system notes' do
+ create(:diff_note_on_design, system: true, noteable: design)
+
+ is_expected.to eq(0)
+ end
+ end
+
+ describe '#after_note_changed' do
+ subject { build(:design) }
+
+ it 'calls #delete_cache on DesignUserNotesCountService' do
+ expect_next_instance_of(DesignManagement::DesignUserNotesCountService) do |service|
+ expect(service).to receive(:delete_cache)
+ end
+
+ subject.after_note_changed(build(:note))
+ end
+
+ it 'does not call #delete_cache on DesignUserNotesCountService when passed a system note' do
+ expect(DesignManagement::DesignUserNotesCountService).not_to receive(:new)
+
+ subject.after_note_changed(build(:note, :system))
+ end
+ end
+
+ describe '.for_reference' do
+ let_it_be(:design_a) { create(:design) }
+ let_it_be(:design_b) { create(:design) }
+
+ it 'avoids extra queries when calling to_reference' do
+ designs = described_class.for_reference.where(id: [design_a.id, design_b.id]).to_a
+
+ expect { designs.map(&:to_reference) }.not_to exceed_query_limit(0)
+ end
+ end
+
+ describe '#to_reference' do
+ let(:namespace) { build(:namespace, path: 'sample-namespace') }
+ let(:project) { build(:project, name: 'sample-project', namespace: namespace) }
+ let(:group) { create(:group, name: 'Group', path: 'sample-group') }
+ let(:issue) { build(:issue, iid: 1, project: project) }
+ let(:filename) { 'homescreen.jpg' }
+ let(:design) { build(:design, filename: filename, issue: issue, project: project) }
+
+ context 'when nil argument' do
+ let(:reference) { design.to_reference }
+
+ it 'uses the simple format' do
+ expect(reference).to eq "#1[homescreen.jpg]"
+ end
+
+ context 'when the filename contains spaces, hyphens, periods, single-quotes, underscores and colons' do
+ let(:filename) { %q{a complex filename: containing - _ : etc., but still 'simple'.gif} }
+
+ it 'uses the simple format' do
+ expect(reference).to eq "#1[#{filename}]"
+ end
+ end
+
+ context 'when the filename contains HTML angle brackets' do
+ let(:filename) { 'a <em>great</em> filename.jpg' }
+
+ it 'uses Base64 encoding' do
+ expect(reference).to eq "#1[base64:#{Base64.strict_encode64(filename)}]"
+ end
+ end
+
+ context 'when the filename contains quotation marks' do
+ let(:filename) { %q{a "great" filename.jpg} }
+
+ it 'uses enclosing quotes, with backslash encoding' do
+ expect(reference).to eq %q{#1["a \"great\" filename.jpg"]}
+ end
+ end
+
+ context 'when the filename contains square brackets' do
+ let(:filename) { %q{a [great] filename.jpg} }
+
+ it 'uses enclosing quotes' do
+ expect(reference).to eq %q{#1["a [great] filename.jpg"]}
+ end
+ end
+ end
+
+ context 'when full is true' do
+ it 'returns complete path to the issue' do
+ refs = [
+ design.to_reference(full: true),
+ design.to_reference(project, full: true),
+ design.to_reference(group, full: true)
+ ]
+
+ expect(refs).to all(eq 'sample-namespace/sample-project#1/designs[homescreen.jpg]')
+ end
+ end
+
+ context 'when full is false' do
+ it 'returns complete path to the issue' do
+ refs = [
+ design.to_reference(build(:project), full: false),
+ design.to_reference(group, full: false)
+ ]
+
+ expect(refs).to all(eq 'sample-namespace/sample-project#1[homescreen.jpg]')
+ end
+ end
+
+ context 'when same project argument' do
+ it 'returns bare reference' do
+ expect(design.to_reference(project)).to eq("#1[homescreen.jpg]")
+ end
+ end
+ end
+
+ describe 'reference_pattern' do
+ let(:match) { described_class.reference_pattern.match(ref) }
+ let(:ref) { design.to_reference }
+ let(:design) { build(:design, filename: filename) }
+
+ context 'simple_file_name' do
+ let(:filename) { 'simple-file-name.jpg' }
+
+ it 'matches :simple_file_name' do
+ expect(match[:simple_file_name]).to eq(filename)
+ end
+ end
+
+ context 'quoted_file_name' do
+ let(:filename) { 'simple "file" name.jpg' }
+
+ it 'matches :simple_file_name' do
+ expect(match[:escaped_filename].gsub(/\\"/, '"')).to eq(filename)
+ end
+ end
+
+ context 'Base64 name' do
+ let(:filename) { '<>.png' }
+
+ it 'matches base_64_encoded_name' do
+ expect(Base64.decode64(match[:base_64_encoded_name])).to eq(filename)
+ end
+ end
+ end
+
+ describe '.by_issue_id_and_filename' do
+ let_it_be(:issue_a) { create(:issue) }
+ let_it_be(:issue_b) { create(:issue) }
+
+ let_it_be(:design_a) { create(:design, issue: issue_a) }
+ let_it_be(:design_b) { create(:design, issue: issue_a) }
+ let_it_be(:design_c) { create(:design, issue: issue_b, filename: design_a.filename) }
+ let_it_be(:design_d) { create(:design, issue: issue_b, filename: design_b.filename) }
+
+ it_behaves_like 'a where_composite scope', :by_issue_id_and_filename do
+ let(:all_results) { [design_a, design_b, design_c, design_d] }
+ let(:first_result) { design_a }
+
+ let(:composite_ids) do
+ all_results.map { |design| { issue_id: design.issue_id, filename: design.filename } }
+ end
+ end
+ end
+end
diff --git a/spec/models/design_management/repository_spec.rb b/spec/models/design_management/repository_spec.rb
new file mode 100644
index 00000000000..996316eeec9
--- /dev/null
+++ b/spec/models/design_management/repository_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::Repository do
+ let(:project) { create(:project) }
+ let(:repository) { described_class.new(project) }
+
+ shared_examples 'returns parsed git attributes that enable LFS for all file types' do
+ it do
+ expect(subject.patterns).to be_a_kind_of(Hash)
+ expect(subject.patterns).to have_key('/designs/*')
+ expect(subject.patterns['/designs/*']).to eql(
+ { "filter" => "lfs", "diff" => "lfs", "merge" => "lfs", "text" => false }
+ )
+ end
+ end
+
+ describe "#info_attributes" do
+ subject { repository.info_attributes }
+
+ include_examples 'returns parsed git attributes that enable LFS for all file types'
+ end
+
+ describe '#attributes_at' do
+ subject { repository.attributes_at }
+
+ include_examples 'returns parsed git attributes that enable LFS for all file types'
+ end
+
+ describe '#gitattribute' do
+ it 'returns a gitattribute when path has gitattributes' do
+ expect(repository.gitattribute('/designs/file.txt', 'filter')).to eq('lfs')
+ end
+
+ it 'returns nil when path has no gitattributes' do
+ expect(repository.gitattribute('/invalid/file.txt', 'filter')).to be_nil
+ end
+ end
+
+ describe '#copy_gitattributes' do
+ it 'always returns regardless of whether given a valid or invalid ref' do
+ expect(repository.copy_gitattributes('master')).to be true
+ expect(repository.copy_gitattributes('invalid')).to be true
+ end
+ end
+
+ describe '#attributes' do
+ it 'confirms that all files are LFS enabled' do
+ %w(png zip anything).each do |filetype|
+ path = "/#{DesignManagement.designs_directory}/file.#{filetype}"
+ attributes = repository.attributes(path)
+
+ expect(attributes['filter']).to eq('lfs')
+ end
+ end
+ end
+end
diff --git a/spec/models/design_management/version_spec.rb b/spec/models/design_management/version_spec.rb
new file mode 100644
index 00000000000..ab6958ea94a
--- /dev/null
+++ b/spec/models/design_management/version_spec.rb
@@ -0,0 +1,342 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe DesignManagement::Version do
+ let_it_be(:issue) { create(:issue) }
+
+ describe 'relations' do
+ it { is_expected.to have_many(:actions) }
+ it { is_expected.to have_many(:designs).through(:actions) }
+
+ it 'constrains the designs relation correctly' do
+ design = create(:design)
+ version = create(:design_version, designs: [design])
+
+ expect { version.designs << design }.to raise_error(ActiveRecord::RecordNotUnique)
+ end
+
+ it 'allows adding multiple versions to a single design' do
+ design = create(:design)
+ versions = create_list(:design_version, 2)
+
+ expect { versions.each { |v| design.versions << v } }
+ .not_to raise_error
+ end
+ end
+
+ describe 'validations' do
+ subject(:design_version) { build(:design_version) }
+
+ it { is_expected.to be_valid }
+ it { is_expected.to validate_presence_of(:author) }
+ it { is_expected.to validate_presence_of(:sha) }
+ it { is_expected.to validate_presence_of(:designs) }
+ it { is_expected.to validate_presence_of(:issue_id) }
+ it { is_expected.to validate_uniqueness_of(:sha).scoped_to(:issue_id).case_insensitive }
+ end
+
+ describe "scopes" do
+ let_it_be(:version_1) { create(:design_version) }
+ let_it_be(:version_2) { create(:design_version) }
+
+ describe ".for_designs" do
+ it "only returns versions related to the specified designs" do
+ _other_version = create(:design_version)
+ designs = [create(:design, versions: [version_1]),
+ create(:design, versions: [version_2])]
+
+ expect(described_class.for_designs(designs))
+ .to contain_exactly(version_1, version_2)
+ end
+ end
+
+ describe '.earlier_or_equal_to' do
+ it 'only returns versions created earlier or later than the given version' do
+ expect(described_class.earlier_or_equal_to(version_1)).to eq([version_1])
+ expect(described_class.earlier_or_equal_to(version_2)).to contain_exactly(version_1, version_2)
+ end
+
+ it 'can be passed either a DesignManagement::Version or an ID' do
+ [version_1, version_1.id].each do |arg|
+ expect(described_class.earlier_or_equal_to(arg)).to eq([version_1])
+ end
+ end
+ end
+
+ describe '.by_sha' do
+ it 'can find versions by sha' do
+ [version_1, version_2].each do |version|
+ expect(described_class.by_sha(version.sha)).to contain_exactly(version)
+ end
+ end
+ end
+ end
+
+ describe ".create_for_designs" do
+ def current_version_id(design)
+ design.send(:head_version).try(:id)
+ end
+
+ def as_actions(designs, action = :create)
+ designs.map do |d|
+ DesignManagement::DesignAction.new(d, action, action == :delete ? nil : :content)
+ end
+ end
+
+ let_it_be(:author) { create(:user) }
+ let_it_be(:design_a) { create(:design, issue: issue) }
+ let_it_be(:design_b) { create(:design, issue: issue) }
+ let_it_be(:designs) { [design_a, design_b] }
+
+ describe 'the error raised when there are no actions' do
+ let_it_be(:sha) { 'f00' }
+
+ def call_with_empty_actions
+ described_class.create_for_designs([], sha, author)
+ end
+
+ it 'raises CouldNotCreateVersion' do
+ expect { call_with_empty_actions }
+ .to raise_error(described_class::CouldNotCreateVersion)
+ end
+
+ it 'has an appropriate cause' do
+ expect { call_with_empty_actions }
+ .to raise_error(have_attributes(cause: ActiveRecord::RecordInvalid))
+ end
+
+ it 'provides extra data sentry can consume' do
+ extra_info = a_hash_including(sha: sha)
+
+ expect { call_with_empty_actions }
+ .to raise_error(have_attributes(sentry_extra_data: extra_info))
+ end
+ end
+
+ describe 'the error raised when the designs come from different issues' do
+ let_it_be(:sha) { 'f00' }
+ let_it_be(:designs) { create_list(:design, 2) }
+ let_it_be(:actions) { as_actions(designs) }
+
+ def call_with_mismatched_designs
+ described_class.create_for_designs(actions, sha, author)
+ end
+
+ it 'raises CouldNotCreateVersion' do
+ expect { call_with_mismatched_designs }
+ .to raise_error(described_class::CouldNotCreateVersion)
+ end
+
+ it 'has an appropriate cause' do
+ expect { call_with_mismatched_designs }
+ .to raise_error(have_attributes(cause: described_class::NotSameIssue))
+ end
+
+ it 'provides extra data sentry can consume' do
+ extra_info = a_hash_including(design_ids: designs.map(&:id))
+
+ expect { call_with_mismatched_designs }
+ .to raise_error(have_attributes(sentry_extra_data: extra_info))
+ end
+ end
+
+ it 'does not leave invalid versions around if creation fails' do
+ expect do
+ described_class.create_for_designs([], 'abcdef', author) rescue nil
+ end.not_to change { described_class.count }
+ end
+
+ it 'does not leave orphaned design-versions around if creation fails' do
+ actions = as_actions(designs)
+ expect do
+ described_class.create_for_designs(actions, '', author) rescue nil
+ end.not_to change { DesignManagement::Action.count }
+ end
+
+ it 'creates a version and links it to multiple designs' do
+ actions = as_actions(designs, :create)
+
+ version = described_class.create_for_designs(actions, 'abc', author)
+
+ expect(version.designs).to contain_exactly(*designs)
+ expect(designs.map(&method(:current_version_id))).to all(eq version.id)
+ end
+
+ it 'creates designs if they are new to git' do
+ actions = as_actions(designs, :create)
+
+ described_class.create_for_designs(actions, 'abc', author)
+
+ expect(designs.map(&:most_recent_action)).to all(be_creation)
+ end
+
+ it 'correctly associates the version with the issue' do
+ actions = as_actions(designs)
+
+ version = described_class.create_for_designs(actions, 'abc', author)
+
+ expect(version.issue).to eq(issue)
+ end
+
+ it 'correctly associates the version with the author' do
+ actions = as_actions(designs)
+
+ version = described_class.create_for_designs(actions, 'abc', author)
+
+ expect(version.author).to eq(author)
+ end
+
+ it 'modifies designs if git updated them' do
+ actions = as_actions(designs, :update)
+
+ described_class.create_for_designs(actions, 'abc', author)
+
+ expect(designs.map(&:most_recent_action)).to all(be_modification)
+ end
+
+ it 'deletes designs when the git action was delete' do
+ actions = as_actions(designs, :delete)
+
+ described_class.create_for_designs(actions, 'def', author)
+
+ expect(designs).to all(be_deleted)
+ end
+
+ it 're-creates designs if they are deleted' do
+ described_class.create_for_designs(as_actions(designs, :create), 'abc', author)
+ described_class.create_for_designs(as_actions(designs, :delete), 'def', author)
+
+ expect(designs).to all(be_deleted)
+
+ described_class.create_for_designs(as_actions(designs, :create), 'ghi', author)
+
+ expect(designs.map(&:most_recent_action)).to all(be_creation)
+ expect(designs).not_to include(be_deleted)
+ end
+
+ it 'changes the version of the designs' do
+ actions = as_actions([design_a])
+ described_class.create_for_designs(actions, 'before', author)
+
+ expect do
+ described_class.create_for_designs(actions, 'after', author)
+ end.to change { current_version_id(design_a) }
+ end
+ end
+
+ describe '#designs_by_event' do
+ context 'there is a single design' do
+ let_it_be(:design) { create(:design) }
+
+ shared_examples :a_correctly_categorised_design do |kind, category|
+ let_it_be(:version) { create(:design_version, kind => [design]) }
+
+ it 'returns a hash with a single key and the single design in that bucket' do
+ expect(version.designs_by_event).to eq(category => [design])
+ end
+ end
+
+ it_behaves_like :a_correctly_categorised_design, :created_designs, 'creation'
+ it_behaves_like :a_correctly_categorised_design, :modified_designs, 'modification'
+ it_behaves_like :a_correctly_categorised_design, :deleted_designs, 'deletion'
+ end
+
+ context 'there are a bunch of different designs in a variety of states' do
+ let_it_be(:version) do
+ create(:design_version,
+ created_designs: create_list(:design, 3),
+ modified_designs: create_list(:design, 4),
+ deleted_designs: create_list(:design, 5))
+ end
+
+ it 'puts them in the right buckets' do
+ expect(version.designs_by_event).to match(
+ a_hash_including(
+ 'creation' => have_attributes(size: 3),
+ 'modification' => have_attributes(size: 4),
+ 'deletion' => have_attributes(size: 5)
+ )
+ )
+ end
+
+ it 'does not suffer from N+1 queries' do
+ version.designs.map(&:id) # we don't care about the set-up queries
+ expect { version.designs_by_event }.not_to exceed_query_limit(2)
+ end
+ end
+ end
+
+ describe '#author' do
+ it 'returns the author' do
+ author = build(:user)
+ version = build(:design_version, author: author)
+
+ expect(version.author).to eq(author)
+ end
+
+ it 'returns nil if author_id is nil and version is not persisted' do
+ version = build(:design_version, author: nil)
+
+ expect(version.author).to eq(nil)
+ end
+
+ it 'retrieves author from the Commit if author_id is nil and version has been persisted' do
+ author = create(:user)
+ version = create(:design_version, :committed, author: author)
+ author.destroy
+ version.reload
+ commit = version.issue.project.design_repository.commit(version.sha)
+ commit_user = create(:user, email: commit.author_email, name: commit.author_name)
+
+ expect(version.author_id).to eq(nil)
+ expect(version.author).to eq(commit_user)
+ end
+ end
+
+ describe '#diff_refs' do
+ let(:project) { issue.project }
+
+ before do
+ expect(project.design_repository).to receive(:commit)
+ .once
+ .with(sha)
+ .and_return(commit)
+ end
+
+ subject { create(:design_version, issue: issue, sha: sha) }
+
+ context 'there is a commit in the repo by the SHA' do
+ let(:commit) { build(:commit) }
+ let(:sha) { commit.id }
+
+ it { is_expected.to have_attributes(diff_refs: commit.diff_refs) }
+
+ it 'memoizes calls to #diff_refs' do
+ expect(subject.diff_refs).to eq(subject.diff_refs)
+ end
+ end
+
+ context 'there is no commit in the repo by the SHA' do
+ let(:commit) { nil }
+ let(:sha) { Digest::SHA1.hexdigest("points to nothing") }
+
+ it { is_expected.to have_attributes(diff_refs: be_nil) }
+ end
+ end
+
+ describe '#reset' do
+ subject { create(:design_version, issue: issue) }
+
+ it 'removes memoized values' do
+ expect(subject).to receive(:commit).twice.and_return(nil)
+
+ subject.diff_refs
+ subject.diff_refs
+
+ subject.reset
+
+ subject.diff_refs
+ subject.diff_refs
+ end
+ end
+end
diff --git a/spec/models/design_user_mention_spec.rb b/spec/models/design_user_mention_spec.rb
new file mode 100644
index 00000000000..03c77c73c8d
--- /dev/null
+++ b/spec/models/design_user_mention_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignUserMention do
+ describe 'associations' do
+ it { is_expected.to belong_to(:design) }
+ it { is_expected.to belong_to(:note) }
+ end
+
+ it_behaves_like 'has user mentions'
+end
diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb
index b802c8ba506..65f06a5b270 100644
--- a/spec/models/diff_note_spec.rb
+++ b/spec/models/diff_note_spec.rb
@@ -287,6 +287,24 @@ describe DiffNote do
reply_diff_note.reload.diff_file
end
end
+
+ context 'when noteable is a Design' do
+ it 'does not return a diff file' do
+ diff_note = create(:diff_note_on_design)
+
+ expect(diff_note.diff_file).to be_nil
+ end
+ end
+ end
+
+ describe '#latest_diff_file' do
+ context 'when noteable is a Design' do
+ it 'does not return a diff file' do
+ diff_note = create(:diff_note_on_design)
+
+ expect(diff_note.latest_diff_file).to be_nil
+ end
+ end
end
describe "#diff_line" do
diff --git a/spec/models/email_spec.rb b/spec/models/email_spec.rb
index aa3a60b867a..f7b194abcee 100644
--- a/spec/models/email_spec.rb
+++ b/spec/models/email_spec.rb
@@ -3,8 +3,14 @@
require 'spec_helper'
describe Email do
+ describe 'modules' do
+ subject { described_class }
+
+ it { is_expected.to include_module(AsyncDeviseEmail) }
+ end
+
describe 'validations' do
- it_behaves_like 'an object with email-formated attributes', :email do
+ it_behaves_like 'an object with RFC3696 compliant email-formated attributes', :email do
subject { build(:email) }
end
end
@@ -45,4 +51,16 @@ describe Email do
expect(build(:email, user: user).username).to eq user.username
end
end
+
+ describe 'Devise emails' do
+ let!(:user) { create(:user) }
+
+ describe 'behaviour' do
+ it 'sends emails asynchronously' do
+ expect do
+ user.emails.create!(email: 'hello@hello.com')
+ end.to have_enqueued_job.on_queue('mailers')
+ end
+ end
+ end
end
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index d0305d878e3..c0b2a4ae984 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -1311,4 +1311,25 @@ describe Environment, :use_clean_rails_memory_store_caching do
expect { environment.destroy }.to change { project.commit(deployment.ref_path) }.to(nil)
end
end
+
+ describe '.count_by_state' do
+ context 'when environments are not empty' do
+ let!(:environment1) { create(:environment, project: project, state: 'stopped') }
+ let!(:environment2) { create(:environment, project: project, state: 'available') }
+ let!(:environment3) { create(:environment, project: project, state: 'stopped') }
+
+ it 'returns the environments count grouped by state' do
+ expect(project.environments.count_by_state).to eq({ stopped: 2, available: 1 })
+ end
+
+ it 'returns the environments count grouped by state with zero value' do
+ environment2.update(state: 'stopped')
+ expect(project.environments.count_by_state).to eq({ stopped: 3, available: 0 })
+ end
+ end
+
+ it 'returns zero state counts when environments are empty' do
+ expect(project.environments.count_by_state).to eq({ stopped: 0, available: 0 })
+ end
+ end
end
diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb
index 3239c7a843a..ac89f8fe9e1 100644
--- a/spec/models/event_spec.rb
+++ b/spec/models/event_spec.rb
@@ -84,6 +84,21 @@ describe Event do
end
end
+ describe 'scopes' do
+ describe 'created_at' do
+ it 'can find the right event' do
+ time = 1.day.ago
+ event = create(:event, created_at: time)
+ false_positive = create(:event, created_at: 2.days.ago)
+
+ found = described_class.created_at(time)
+
+ expect(found).to include(event)
+ expect(found).not_to include(false_positive)
+ end
+ end
+ end
+
describe "Push event" do
let(:project) { create(:project, :private) }
let(:user) { project.owner }
@@ -195,11 +210,13 @@ describe Event do
let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) }
let(:project_snippet) { create(:project_snippet, :public, project: project, author: author) }
let(:personal_snippet) { create(:personal_snippet, :public, author: author) }
+ let(:design) { create(:design, issue: issue, project: project) }
let(:note_on_commit) { create(:note_on_commit, project: project) }
let(:note_on_issue) { create(:note_on_issue, noteable: issue, project: project) }
let(:note_on_confidential_issue) { create(:note_on_issue, noteable: confidential_issue, project: project) }
let(:note_on_project_snippet) { create(:note_on_project_snippet, author: author, noteable: project_snippet, project: project) }
let(:note_on_personal_snippet) { create(:note_on_personal_snippet, author: author, noteable: personal_snippet, project: nil) }
+ let(:note_on_design) { create(:note_on_design, author: author, noteable: design, project: project) }
let(:milestone_on_project) { create(:milestone, project: project) }
let(:event) do
described_class.new(project: project,
@@ -270,8 +287,16 @@ describe Event do
context 'private project' do
let(:project) { create(:project, :private, :repository) }
- include_examples 'visibility examples' do
- let(:visibility) { visible_to_none_except(:member, :admin) }
+ context 'when admin mode enabled', :enable_admin_mode do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_none_except(:member, :admin) }
+ end
+ end
+
+ context 'when admin mode disabled' do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_none_except(:member) }
+ end
end
end
end
@@ -283,6 +308,7 @@ describe Event do
include_examples 'visibility examples' do
let(:visibility) { visible_to_all }
end
+
include_examples 'visible to assignee and author', true
end
@@ -292,6 +318,7 @@ describe Event do
include_examples 'visibility examples' do
let(:visibility) { visible_to_none_except(:member, :admin) }
end
+
include_examples 'visible to assignee and author', true
end
end
@@ -303,6 +330,7 @@ describe Event do
include_examples 'visibility examples' do
let(:visibility) { visible_to_all }
end
+
include_examples 'visible to assignee and author', true
end
@@ -312,6 +340,7 @@ describe Event do
include_examples 'visibility examples' do
let(:visibility) { visible_to_none_except(:member, :admin) }
end
+
include_examples 'visible to assignee and author', true
end
@@ -319,8 +348,16 @@ describe Event do
let(:project) { private_project }
let(:target) { note_on_issue }
- include_examples 'visibility examples' do
- let(:visibility) { visible_to_none_except(:guest, :member, :admin) }
+ context 'when admin mode enabled', :enable_admin_mode do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_none_except(:guest, :member, :admin) }
+ end
+ end
+
+ context 'when admin mode disabled' do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_none_except(:guest, :member) }
+ end
end
include_examples 'visible to assignee and author', false
@@ -345,8 +382,16 @@ describe Event do
context 'private project' do
let(:project) { private_project }
- include_examples 'visibility examples' do
- let(:visibility) { visible_to_none_except(:member, :admin) }
+ context 'when admin mode enabled', :enable_admin_mode do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_none_except(:member, :admin) }
+ end
+ end
+
+ context 'when admin mode disabled' do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_none_except(:member) }
+ end
end
include_examples 'visible to assignee', false
@@ -363,16 +408,32 @@ describe Event do
context 'on public project with private issue tracker and merge requests' do
let(:project) { create(:project, :public, :issues_private, :merge_requests_private) }
- include_examples 'visibility examples' do
- let(:visibility) { visible_to_all_except(:logged_out, :non_member) }
+ context 'when admin mode enabled', :enable_admin_mode do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_all_except(:logged_out, :non_member) }
+ end
+ end
+
+ context 'when admin mode disabled' do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_all_except(:logged_out, :non_member, :admin) }
+ end
end
end
context 'on private project' do
let(:project) { create(:project, :private) }
- include_examples 'visibility examples' do
- let(:visibility) { visible_to_all_except(:logged_out, :non_member) }
+ context 'when admin mode enabled', :enable_admin_mode do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_all_except(:logged_out, :non_member) }
+ end
+ end
+
+ context 'when admin mode disabled' do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_all_except(:logged_out, :non_member, :admin) }
+ end
end
end
end
@@ -383,8 +444,16 @@ describe Event do
context 'on private project', :aggregate_failures do
let(:project) { create(:project, :wiki_repo) }
- include_examples 'visibility examples' do
- let(:visibility) { visible_to_all_except(:logged_out, :non_member) }
+ context 'when admin mode enabled', :enable_admin_mode do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_all_except(:logged_out, :non_member) }
+ end
+ end
+
+ context 'when admin mode disabled' do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_all_except(:logged_out, :non_member, :admin) }
+ end
end
end
@@ -407,22 +476,42 @@ describe Event do
context 'on public project with private snippets' do
let(:project) { create(:project, :public, :snippets_private) }
- include_examples 'visibility examples' do
- let(:visibility) { visible_to_none_except(:guest, :member, :admin) }
+ context 'when admin mode enabled', :enable_admin_mode do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_none_except(:guest, :member, :admin) }
+ end
+ end
+
+ context 'when admin mode disabled' do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_none_except(:guest, :member) }
+ end
end
+
# Normally, we'd expect the author of a comment to be able to view it.
# However, this doesn't seem to be the case for comments on snippets.
+
include_examples 'visible to author', false
end
context 'on private project' do
let(:project) { create(:project, :private) }
- include_examples 'visibility examples' do
- let(:visibility) { visible_to_none_except(:guest, :member, :admin) }
+ context 'when admin mode enabled', :enable_admin_mode do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_none_except(:guest, :member, :admin) }
+ end
end
+
+ context 'when admin mode disabled' do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_none_except(:guest, :member) }
+ end
+ end
+
# Normally, we'd expect the author of a comment to be able to view it.
# However, this doesn't seem to be the case for comments on snippets.
+
include_examples 'visible to author', false
end
end
@@ -433,6 +522,7 @@ describe Event do
include_examples 'visibility examples' do
let(:visibility) { visible_to_all }
end
+
include_examples 'visible to author', true
context 'on internal snippet' do
@@ -446,12 +536,47 @@ describe Event do
context 'on private snippet' do
let(:personal_snippet) { create(:personal_snippet, :private, author: author) }
- include_examples 'visibility examples' do
- let(:visibility) { visible_to_none_except(:admin) }
+ context 'when admin mode enabled', :enable_admin_mode do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_none_except(:admin) }
+ end
end
+
+ context 'when admin mode disabled' do
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_none }
+ end
+ end
+
include_examples 'visible to author', true
end
end
+
+ context 'design event' do
+ include DesignManagementTestHelpers
+
+ let(:target) { note_on_design }
+
+ before do
+ enable_design_management
+ end
+
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_all }
+ end
+
+ include_examples 'visible to assignee and author', true
+
+ context 'the event refers to a design on a confidential issue' do
+ let(:design) { create(:design, issue: confidential_issue, project: project) }
+
+ include_examples 'visibility examples' do
+ let(:visibility) { visible_to_none_except(:member, :admin) }
+ end
+
+ include_examples 'visible to assignee and author', true
+ end
+ end
end
describe 'wiki_page predicate scopes' do
@@ -483,6 +608,14 @@ describe Event do
expect(described_class.not_wiki_page).to match_array(non_wiki_events)
end
end
+
+ describe '.for_wiki_meta' do
+ it 'finds events for a given wiki page metadata object' do
+ event = events.select(&:wiki_page?).first
+
+ expect(described_class.for_wiki_meta(event.target)).to contain_exactly(event)
+ end
+ end
end
describe '#wiki_page and #wiki_page?' do
@@ -490,7 +623,7 @@ describe Event do
context 'for a wiki page event' do
let(:wiki_page) do
- create(:wiki_page, :with_real_page, project: project)
+ create(:wiki_page, project: project)
end
subject(:event) { create(:wiki_page_event, project: project, wiki_page: wiki_page) }
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index 576ac880fca..a4e49f88115 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -24,6 +24,8 @@ describe Group do
it { is_expected.to have_many(:cluster_groups).class_name('Clusters::Group') }
it { is_expected.to have_many(:clusters).class_name('Clusters::Cluster') }
it { is_expected.to have_many(:container_repositories) }
+ it { is_expected.to have_many(:milestones) }
+ it { is_expected.to have_many(:iterations) }
describe '#members & #requesters' do
let(:requester) { create(:user) }
@@ -553,114 +555,72 @@ describe Group do
group_access: GroupMember::DEVELOPER })
end
- context 'when feature flag share_group_with_group is enabled' do
- before do
- stub_feature_flags(share_group_with_group: true)
- end
-
- context 'with user in the group' do
- let(:user) { group_user }
-
- it 'returns correct access level' do
- expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::DEVELOPER)
- expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::DEVELOPER)
- end
+ context 'with user in the group' do
+ let(:user) { group_user }
- context 'with lower group access level than max access level for share' do
- let(:user) { create(:user) }
-
- it 'returns correct access level' do
- group.add_reporter(user)
-
- expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::REPORTER)
- expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::REPORTER)
- end
- end
+ it 'returns correct access level' do
+ expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
+ expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::DEVELOPER)
+ expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::DEVELOPER)
end
- context 'with user in the parent group' do
- let(:user) { parent_group_user }
+ context 'with lower group access level than max access level for share' do
+ let(:user) { create(:user) }
it 'returns correct access level' do
- expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- end
- end
-
- context 'with user in the child group' do
- let(:user) { child_group_user }
+ group.add_reporter(user)
- it 'returns correct access level' do
expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
+ expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::REPORTER)
+ expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::REPORTER)
end
end
+ end
- context 'unrelated project owner' do
- let(:common_id) { [Project.maximum(:id).to_i, Namespace.maximum(:id).to_i].max + 999 }
- let!(:group) { create(:group, id: common_id) }
- let!(:unrelated_project) { create(:project, id: common_id) }
- let(:user) { unrelated_project.owner }
+ context 'with user in the parent group' do
+ let(:user) { parent_group_user }
- it 'returns correct access level' do
- expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- end
+ it 'returns correct access level' do
+ expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
+ expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
+ expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
end
+ end
- context 'user without accepted access request' do
- let!(:user) { create(:user) }
-
- before do
- create(:group_member, :developer, :access_request, user: user, group: group)
- end
+ context 'with user in the child group' do
+ let(:user) { child_group_user }
- it 'returns correct access level' do
- expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- end
+ it 'returns correct access level' do
+ expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
+ expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
+ expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
end
end
- context 'when feature flag share_group_with_group is disabled' do
- before do
- stub_feature_flags(share_group_with_group: false)
- end
-
- context 'with user in the group' do
- let(:user) { group_user }
+ context 'unrelated project owner' do
+ let(:common_id) { [Project.maximum(:id).to_i, Namespace.maximum(:id).to_i].max + 999 }
+ let!(:group) { create(:group, id: common_id) }
+ let!(:unrelated_project) { create(:project, id: common_id) }
+ let(:user) { unrelated_project.owner }
- it 'returns correct access level' do
- expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- end
+ it 'returns correct access level' do
+ expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
+ expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
+ expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
end
+ end
- context 'with user in the parent group' do
- let(:user) { parent_group_user }
+ context 'user without accepted access request' do
+ let!(:user) { create(:user) }
- it 'returns correct access level' do
- expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- end
+ before do
+ create(:group_member, :developer, :access_request, user: user, group: group)
end
- context 'with user in the child group' do
- let(:user) { child_group_user }
-
- it 'returns correct access level' do
- expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
- end
+ it 'returns correct access level' do
+ expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
+ expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
+ expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS)
end
end
end
@@ -672,8 +632,6 @@ describe Group do
let(:shared_group) { create(:group, :private, parent: shared_group_parent) }
before do
- stub_feature_flags(share_group_with_group: true)
-
group.add_owner(user)
create(:group_group_link, { shared_with_group: group,
@@ -701,6 +659,42 @@ describe Group do
end
end
+ describe '#members_from_self_and_ancestors_with_effective_access_level' do
+ let!(:group_parent) { create(:group, :private) }
+ let!(:group) { create(:group, :private, parent: group_parent) }
+ let!(:group_child) { create(:group, :private, parent: group) }
+
+ let!(:user) { create(:user) }
+
+ let(:parent_group_access_level) { Gitlab::Access::REPORTER }
+ let(:group_access_level) { Gitlab::Access::DEVELOPER }
+ let(:child_group_access_level) { Gitlab::Access::MAINTAINER }
+
+ 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, user: user, group: group_child, access_level: child_group_access_level)
+ end
+
+ it 'returns effective access level for user' do
+ expect(group_parent.members_from_self_and_ancestors_with_effective_access_level.as_json).to(
+ contain_exactly(
+ hash_including('user_id' => user.id, 'access_level' => parent_group_access_level)
+ )
+ )
+ expect(group.members_from_self_and_ancestors_with_effective_access_level.as_json).to(
+ contain_exactly(
+ hash_including('user_id' => user.id, 'access_level' => group_access_level)
+ )
+ )
+ expect(group_child.members_from_self_and_ancestors_with_effective_access_level.as_json).to(
+ contain_exactly(
+ hash_including('user_id' => user.id, 'access_level' => child_group_access_level)
+ )
+ )
+ end
+ end
+
describe '#direct_and_indirect_members' do
let!(:group) { create(:group, :nested) }
let!(:sub_group) { create(:group, parent: group) }
diff --git a/spec/models/hooks/project_hook_spec.rb b/spec/models/hooks/project_hook_spec.rb
index a945f0d1516..ccf8171049d 100644
--- a/spec/models/hooks/project_hook_spec.rb
+++ b/spec/models/hooks/project_hook_spec.rb
@@ -11,6 +11,10 @@ describe ProjectHook do
it { is_expected.to validate_presence_of(:project) }
end
+ it_behaves_like 'includes Limitable concern' do
+ subject { build(:project_hook, project: create(:project)) }
+ end
+
describe '.push_hooks' do
it 'returns hooks for push events only' do
hook = create(:project_hook, push_events: true)
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index e8103be0682..dd5ff3dbdde 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -7,13 +7,30 @@ describe Issue do
describe "Associations" do
it { is_expected.to belong_to(:milestone) }
+ it { is_expected.to belong_to(:iteration) }
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:moved_to).class_name('Issue') }
+ it { is_expected.to have_one(:moved_from).class_name('Issue') }
it { is_expected.to belong_to(:duplicated_to).class_name('Issue') }
it { is_expected.to belong_to(:closed_by).class_name('User') }
it { is_expected.to have_many(:assignees) }
it { is_expected.to have_many(:user_mentions).class_name("IssueUserMention") }
+ it { is_expected.to have_many(:designs) }
+ it { is_expected.to have_many(:design_versions) }
it { is_expected.to have_one(:sentry_issue) }
+ it { is_expected.to have_one(:alert_management_alert) }
+ it { is_expected.to have_many(:resource_milestone_events) }
+ it { is_expected.to have_many(:resource_state_events) }
+
+ describe 'versions.most_recent' do
+ it 'returns the most recent version' do
+ issue = create(:issue)
+ create_list(:design_version, 2, issue: issue)
+ last_version = create(:design_version, issue: issue)
+
+ expect(issue.design_versions.most_recent).to eq(last_version)
+ end
+ end
end
describe 'modules' do
@@ -23,6 +40,8 @@ describe Issue do
it { is_expected.to include_module(Referable) }
it { is_expected.to include_module(Sortable) }
it { is_expected.to include_module(Taskable) }
+ it { is_expected.to include_module(MilestoneEventable) }
+ it { is_expected.to include_module(StateEventable) }
it_behaves_like 'AtomicInternalId' do
let(:internal_id_attribute) { :iid }
@@ -61,6 +80,18 @@ describe Issue do
end
end
+ describe '.with_alert_management_alerts' do
+ subject { described_class.with_alert_management_alerts }
+
+ it 'gets only issues with alerts' do
+ alert = create(:alert_management_alert, issue: create(:issue))
+ issue = create(:issue)
+
+ expect(subject).to contain_exactly(alert.issue)
+ expect(subject).not_to include(issue)
+ end
+ end
+
describe 'locking' do
using RSpec::Parameterized::TableSyntax
@@ -593,8 +624,15 @@ describe Issue do
context 'with an admin user' do
let(:user) { build(:admin) }
- it_behaves_like 'issue readable by user'
- it_behaves_like 'confidential issue readable by user'
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it_behaves_like 'issue readable by user'
+ it_behaves_like 'confidential issue readable by user'
+ end
+
+ context 'when admin mode is disabled' do
+ it_behaves_like 'issue not readable by user'
+ it_behaves_like 'confidential issue not readable by user'
+ end
end
context 'with an owner' do
@@ -713,13 +751,29 @@ describe Issue do
expect(issue.visible_to_user?(user)).to be_falsy
end
- it 'does not check the external webservice for admins' do
- issue = build(:issue)
- user = build(:admin)
+ context 'with an admin' do
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'does not check the external webservice' do
+ issue = build(:issue)
+ user = build(:admin)
- expect(::Gitlab::ExternalAuthorization).not_to receive(:access_allowed?)
+ expect(::Gitlab::ExternalAuthorization).not_to receive(:access_allowed?)
- issue.visible_to_user?(user)
+ issue.visible_to_user?(user)
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'checks the external service to determine if an issue is readable by the admin' do
+ project = build(:project, :public,
+ external_authorization_classification_label: 'a-label')
+ issue = build(:issue, project: project)
+ user = build(:admin)
+
+ expect(::Gitlab::ExternalAuthorization).to receive(:access_allowed?).with(user, 'a-label') { false }
+ expect(issue.visible_to_user?(user)).to be_falsy
+ end
+ end
end
end
@@ -967,4 +1021,68 @@ describe Issue do
expect(issue.previous_updated_at).to eq(Time.new(2013, 02, 06))
end
end
+
+ describe '#design_collection' do
+ it 'returns a design collection' do
+ issue = build(:issue)
+ collection = issue.design_collection
+
+ expect(collection).to be_a(DesignManagement::DesignCollection)
+ expect(collection.issue).to eq(issue)
+ end
+ end
+
+ describe 'current designs' do
+ let(:issue) { create(:issue) }
+
+ subject { issue.designs.current }
+
+ context 'an issue has no designs' do
+ it { is_expected.to be_empty }
+ end
+
+ context 'an issue only has current designs' do
+ let!(:design_a) { create(:design, :with_file, issue: issue) }
+ let!(:design_b) { create(:design, :with_file, issue: issue) }
+ let!(:design_c) { create(:design, :with_file, issue: issue) }
+
+ it { is_expected.to include(design_a, design_b, design_c) }
+ end
+
+ context 'an issue only has deleted designs' do
+ let!(:design_a) { create(:design, :with_file, issue: issue, deleted: true) }
+ let!(:design_b) { create(:design, :with_file, issue: issue, deleted: true) }
+ let!(:design_c) { create(:design, :with_file, issue: issue, deleted: true) }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'an issue has a mixture of current and deleted designs' do
+ let!(:design_a) { create(:design, :with_file, issue: issue) }
+ let!(:design_b) { create(:design, :with_file, issue: issue, deleted: true) }
+ let!(:design_c) { create(:design, :with_file, issue: issue) }
+
+ it { is_expected.to contain_exactly(design_a, design_c) }
+ end
+ end
+
+ describe '.with_label_attributes' do
+ subject { described_class.with_label_attributes(label_attributes) }
+
+ let(:label_attributes) { { title: 'hello world', description: 'hi' } }
+
+ it 'gets issues with given label attributes' do
+ label = create(:label, **label_attributes)
+ labeled_issue = create(:labeled_issue, project: label.project, labels: [label])
+
+ expect(subject).to include(labeled_issue)
+ end
+
+ it 'excludes issues without given label attributes' do
+ label = create(:label, title: 'GitLab', description: 'tanuki')
+ labeled_issue = create(:labeled_issue, project: label.project, labels: [label])
+
+ expect(subject).not_to include(labeled_issue)
+ end
+ end
end
diff --git a/spec/models/iteration_spec.rb b/spec/models/iteration_spec.rb
new file mode 100644
index 00000000000..e5b7b746639
--- /dev/null
+++ b/spec/models/iteration_spec.rb
@@ -0,0 +1,170 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Iteration do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:group) { create(:group) }
+
+ it_behaves_like 'a timebox', :iteration do
+ let(:timebox_table_name) { described_class.table_name.to_sym }
+ end
+
+ describe "#iid" do
+ it "is properly scoped on project and group" do
+ iteration1 = create(:iteration, project: project)
+ iteration2 = create(:iteration, project: project)
+ iteration3 = create(:iteration, group: group)
+ iteration4 = create(:iteration, group: group)
+ iteration5 = create(:iteration, project: project)
+
+ want = {
+ iteration1: 1,
+ iteration2: 2,
+ iteration3: 1,
+ iteration4: 2,
+ iteration5: 3
+ }
+ got = {
+ iteration1: iteration1.iid,
+ iteration2: iteration2.iid,
+ iteration3: iteration3.iid,
+ iteration4: iteration4.iid,
+ iteration5: iteration5.iid
+ }
+ expect(got).to eq(want)
+ end
+ end
+
+ context 'Validations' do
+ subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) }
+
+ describe '#dates_do_not_overlap' do
+ let_it_be(:existing_iteration) { create(:iteration, group: group, start_date: 4.days.from_now, due_date: 1.week.from_now) }
+
+ context 'when no Iteration dates overlap' do
+ let(:start_date) { 2.weeks.from_now }
+ let(:due_date) { 3.weeks.from_now }
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'when dates overlap' do
+ context 'same group' do
+ context 'when start_date is in range' do
+ let(:start_date) { 5.days.from_now }
+ let(:due_date) { 3.weeks.from_now }
+
+ it 'is not valid' do
+ expect(subject).not_to be_valid
+ expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
+ end
+ end
+
+ context 'when end_date is in range' do
+ let(:start_date) { Time.now }
+ let(:due_date) { 6.days.from_now }
+
+ it 'is not valid' do
+ expect(subject).not_to be_valid
+ expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
+ end
+ end
+
+ context 'when both overlap' do
+ let(:start_date) { 5.days.from_now }
+ let(:due_date) { 6.days.from_now }
+
+ it 'is not valid' do
+ expect(subject).not_to be_valid
+ expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
+ end
+ end
+ end
+
+ context 'different group' do
+ let(:start_date) { 5.days.from_now }
+ let(:due_date) { 6.days.from_now }
+ let(:group) { create(:group) }
+
+ it { is_expected.to be_valid }
+ end
+ end
+ end
+
+ describe '#future_date' do
+ context 'when dates are in the future' do
+ let(:start_date) { Time.now }
+ let(:due_date) { 1.week.from_now }
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'when start_date is in the past' do
+ let(:start_date) { 1.week.ago }
+ let(:due_date) { 1.week.from_now }
+
+ it 'is not valid' do
+ expect(subject).not_to be_valid
+ expect(subject.errors[:start_date]).to include('cannot be in the past')
+ end
+ end
+
+ context 'when due_date is in the past' do
+ let(:start_date) { Time.now }
+ let(:due_date) { 1.week.ago }
+
+ it 'is not valid' do
+ expect(subject).not_to be_valid
+ expect(subject.errors[:due_date]).to include('cannot be in the past')
+ end
+ end
+
+ context 'when start_date is over 500 years in the future' do
+ let(:start_date) { 501.years.from_now }
+ let(:due_date) { Time.now }
+
+ it 'is not valid' do
+ expect(subject).not_to be_valid
+ expect(subject.errors[:start_date]).to include('cannot be more than 500 years in the future')
+ end
+ end
+
+ context 'when due_date is over 500 years in the future' do
+ let(:start_date) { Time.now }
+ let(:due_date) { 501.years.from_now }
+
+ it 'is not valid' do
+ expect(subject).not_to be_valid
+ expect(subject.errors[:due_date]).to include('cannot be more than 500 years in the future')
+ end
+ end
+ end
+ end
+
+ describe '.within_timeframe' do
+ let_it_be(:now) { Time.now }
+ let_it_be(:project) { create(:project, :empty_repo) }
+ let_it_be(:iteration_1) { create(:iteration, project: project, start_date: now, due_date: 1.day.from_now) }
+ let_it_be(:iteration_2) { create(:iteration, project: project, start_date: 2.days.from_now, due_date: 3.days.from_now) }
+ let_it_be(:iteration_3) { create(:iteration, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
+
+ it 'returns iterations with start_date and/or end_date between timeframe' do
+ iterations = described_class.within_timeframe(2.days.from_now, 3.days.from_now)
+
+ expect(iterations).to match_array([iteration_2])
+ end
+
+ it 'returns iterations which starts before the timeframe' do
+ iterations = described_class.within_timeframe(1.day.from_now, 3.days.from_now)
+
+ expect(iterations).to match_array([iteration_1, iteration_2])
+ end
+
+ it 'returns iterations which ends after the timeframe' do
+ iterations = described_class.within_timeframe(3.days.from_now, 5.days.from_now)
+
+ expect(iterations).to match_array([iteration_2, iteration_3])
+ end
+ end
+end
diff --git a/spec/models/jira_import_state_spec.rb b/spec/models/jira_import_state_spec.rb
index 4d91bf25b5e..99f9e035205 100644
--- a/spec/models/jira_import_state_spec.rb
+++ b/spec/models/jira_import_state_spec.rb
@@ -124,6 +124,7 @@ describe JiraImportState do
jira_import.schedule
expect(jira_import.jid).to eq('some-job-id')
+ expect(jira_import.scheduled_at).to be_within(1.second).of(Time.now)
end
end
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index eeb2350359c..a8d864ad3f0 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -241,10 +241,22 @@ describe Member do
expect(member).to be_persisted
end
- it 'sets members.created_by to the given current_user' do
- member = described_class.add_user(source, user, :maintainer, current_user: admin)
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'sets members.created_by to the given admin current_user' do
+ member = described_class.add_user(source, user, :maintainer, current_user: admin)
- expect(member.created_by).to eq(admin)
+ expect(member.created_by).to eq(admin)
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ # Skipped because `Group#max_member_access_for_user` needs to be migrated to use admin mode
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/207950
+ xit 'rejects setting members.created_by to the given admin current_user' do
+ member = described_class.add_user(source, user, :maintainer, current_user: admin)
+
+ expect(member.created_by).not_to be_persisted
+ end
end
it 'sets members.expires_at to the given expires_at' do
@@ -353,7 +365,7 @@ describe Member do
end
end
- context 'when current_user can update member' do
+ context 'when current_user can update member', :enable_admin_mode do
it 'creates the member' do
expect(source.users).not_to include(user)
@@ -421,7 +433,7 @@ describe Member do
end
end
- context 'when current_user can update member' do
+ context 'when current_user can update member', :enable_admin_mode do
it 'updates the member' do
expect(source.users).to include(user)
diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb
index 016af4f269b..0839dde696a 100644
--- a/spec/models/merge_request_diff_spec.rb
+++ b/spec/models/merge_request_diff_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
describe MergeRequestDiff do
+ using RSpec::Parameterized::TableSyntax
+
include RepoHelpers
let(:diff_with_commits) { create(:merge_request).merge_request_diff }
@@ -125,18 +127,71 @@ describe MergeRequestDiff do
end
end
+ describe '#update_external_diff_store' do
+ let_it_be(:merge_request) { create(:merge_request) }
+
+ let(:diff) { merge_request.merge_request_diff }
+ let(:store) { diff.external_diff.object_store }
+
+ where(:change_stored_externally, :change_external_diff) do
+ false | false
+ false | true
+ true | false
+ true | true
+ end
+
+ with_them do
+ it do
+ diff.stored_externally = true if change_stored_externally
+ diff.external_diff = "new-filename" if change_external_diff
+
+ update_store = receive(:update_column).with(:external_diff_store, store)
+
+ if change_stored_externally || change_external_diff
+ expect(diff).to update_store
+ else
+ expect(diff).not_to update_store
+ end
+
+ diff.save!
+ end
+ end
+ end
+
describe '#migrate_files_to_external_storage!' do
+ let(:uploader) { ExternalDiffUploader }
+ let(:file_store) { uploader::Store::LOCAL }
+ let(:remote_store) { uploader::Store::REMOTE }
let(:diff) { create(:merge_request).merge_request_diff }
- it 'converts from in-database to external storage' do
+ it 'converts from in-database to external file storage' do
expect(diff).not_to be_stored_externally
stub_external_diffs_setting(enabled: true)
- expect(diff).to receive(:save!)
+
+ expect(diff).to receive(:save!).and_call_original
+
+ diff.migrate_files_to_external_storage!
+
+ expect(diff).to be_stored_externally
+ expect(diff.external_diff_store).to eq(file_store)
+ end
+
+ it 'converts from in-database to external object storage' do
+ expect(diff).not_to be_stored_externally
+
+ stub_external_diffs_setting(enabled: true)
+
+ # Without direct_upload: true, the files would be saved to disk, and a
+ # background job would be enqueued to move the file to object storage
+ stub_external_diffs_object_storage(uploader, direct_upload: true)
+
+ expect(diff).to receive(:save!).and_call_original
diff.migrate_files_to_external_storage!
expect(diff).to be_stored_externally
+ expect(diff.external_diff_store).to eq(remote_store)
end
it 'does nothing with an external diff' do
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index cbb837c139e..fc4590f7b22 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -18,6 +18,10 @@ describe MergeRequest do
it { is_expected.to have_many(:assignees).through(:merge_request_assignees) }
it { is_expected.to have_many(:merge_request_diffs) }
it { is_expected.to have_many(:user_mentions).class_name("MergeRequestUserMention") }
+ it { is_expected.to belong_to(:milestone) }
+ it { is_expected.to belong_to(:iteration) }
+ it { is_expected.to have_many(:resource_milestone_events) }
+ it { is_expected.to have_many(:resource_state_events) }
context 'for forks' do
let!(:project) { create(:project) }
@@ -176,6 +180,8 @@ describe MergeRequest do
it { is_expected.to include_module(Referable) }
it { is_expected.to include_module(Sortable) }
it { is_expected.to include_module(Taskable) }
+ it { is_expected.to include_module(MilestoneEventable) }
+ it { is_expected.to include_module(StateEventable) }
it_behaves_like 'AtomicInternalId' do
let(:internal_id_attribute) { :iid }
@@ -1610,6 +1616,32 @@ describe MergeRequest do
end
end
+ 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) }
+
+ it { is_expected.to be_truthy }
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(accessibility_report_view: false)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ context 'when head pipeline does not have accessibility reports' do
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
describe '#has_coverage_reports?' do
subject { merge_request.has_coverage_reports? }
@@ -1628,6 +1660,26 @@ describe MergeRequest do
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)
+
+ expect(merge_request.has_terraform_reports?).to be_truthy
+ end
+ end
+
+ context 'when head pipeline does not have terraform reports' do
+ it 'returns false' do
+ merge_request = create(:merge_request, source_project: project)
+
+ expect(merge_request.has_terraform_reports?).to be_falsey
+ end
+ end
+ end
+
describe '#calculate_reactive_cache' do
let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, source_project: project) }
@@ -1837,6 +1889,62 @@ describe MergeRequest do
end
end
+ describe '#compare_accessibility_reports' do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:merge_request, reload: true) { create(:merge_request, :with_accessibility_reports, source_project: project) }
+ let_it_be(:pipeline) { merge_request.head_pipeline }
+
+ subject { merge_request.compare_accessibility_reports }
+
+ context 'when head pipeline has accessibility reports' do
+ let(:job) do
+ create(:ci_build, options: { artifacts: { reports: { pa11y: ['accessibility.json'] } } }, 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 parsing status' do
+ expect(subject[:status]).to eq(:parsing)
+ end
+ end
+
+ context 'when reactive cache worker is inline' do
+ before do
+ synchronous_reactive_cache(merge_request)
+ end
+
+ it 'returns parsed status' do
+ expect(subject[:status]).to eq(:parsed)
+ expect(subject[:data]).to be_present
+ end
+
+ context 'when an error occurrs' do
+ before do
+ merge_request.update!(head_pipeline: nil)
+ end
+
+ it 'returns an error status' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:status_reason]).to eq("This merge request does not have accessibility reports")
+ end
+ end
+
+ context 'when cached result is not latest' do
+ before do
+ allow_next_instance_of(Ci::CompareAccessibilityReportsService) do |service|
+ allow(service).to receive(:latest?).and_return(false)
+ end
+ end
+
+ it 'raises an InvalidateReactiveCache error' do
+ expect { subject }.to raise_error(ReactiveCaching::InvalidateReactiveCache)
+ end
+ end
+ end
+ end
+ end
+
describe '#all_commit_shas' do
context 'when merge request is persisted' do
let(:all_commit_shas) do
@@ -3678,41 +3786,41 @@ describe MergeRequest do
describe '#recent_visible_deployments' do
let(:merge_request) { create(:merge_request) }
- let(:environment) do
- create(:environment, project: merge_request.target_project)
- end
-
it 'returns visible deployments' do
+ envs = create_list(:environment, 3, project: merge_request.target_project)
+
created = create(
:deployment,
:created,
project: merge_request.target_project,
- environment: environment
+ environment: envs[0]
)
success = create(
:deployment,
:success,
project: merge_request.target_project,
- environment: environment
+ environment: envs[1]
)
failed = create(
:deployment,
:failed,
project: merge_request.target_project,
- environment: environment
+ environment: envs[2]
)
- merge_request.deployment_merge_requests.create!(deployment: created)
- merge_request.deployment_merge_requests.create!(deployment: success)
- merge_request.deployment_merge_requests.create!(deployment: failed)
+ merge_request_relation = MergeRequest.where(id: merge_request.id)
+ created.link_merge_requests(merge_request_relation)
+ success.link_merge_requests(merge_request_relation)
+ failed.link_merge_requests(merge_request_relation)
expect(merge_request.recent_visible_deployments).to eq([failed, success])
end
it 'only returns a limited number of deployments' do
20.times do
+ environment = create(:environment, project: merge_request.target_project)
deploy = create(
:deployment,
:success,
@@ -3720,7 +3828,7 @@ describe MergeRequest do
environment: environment
)
- merge_request.deployment_merge_requests.create!(deployment: deploy)
+ deploy.link_merge_requests(MergeRequest.where(id: merge_request.id))
end
expect(merge_request.recent_visible_deployments.count).to eq(10)
@@ -3728,40 +3836,28 @@ describe MergeRequest do
end
describe '#diffable_merge_ref?' do
- context 'diff_compare_with_head enabled' do
- context 'merge request can be merged' do
- context 'merge_to_ref is not calculated' do
- it 'returns true' do
- expect(subject.diffable_merge_ref?).to eq(false)
- end
- end
-
- context 'merge_to_ref is calculated' do
- before do
- MergeRequests::MergeToRefService.new(subject.project, subject.author).execute(subject)
- end
-
- it 'returns true' do
- expect(subject.diffable_merge_ref?).to eq(true)
- end
+ context 'merge request can be merged' do
+ context 'merge_to_ref is not calculated' do
+ it 'returns true' do
+ expect(subject.diffable_merge_ref?).to eq(false)
end
end
- context 'merge request cannot be merged' do
- it 'returns false' do
- subject.mark_as_unchecked!
+ context 'merge_to_ref is calculated' do
+ before do
+ MergeRequests::MergeToRefService.new(subject.project, subject.author).execute(subject)
+ end
- expect(subject.diffable_merge_ref?).to eq(false)
+ it 'returns true' do
+ expect(subject.diffable_merge_ref?).to eq(true)
end
end
end
- context 'diff_compare_with_head disabled' do
- before do
- stub_feature_flags(diff_compare_with_head: { enabled: false, thing: subject.target_project })
- end
-
+ context 'merge request cannot be merged' do
it 'returns false' do
+ subject.mark_as_unchecked!
+
expect(subject.diffable_merge_ref?).to eq(false)
end
end
diff --git a/spec/models/metrics/users_starred_dashboard_spec.rb b/spec/models/metrics/users_starred_dashboard_spec.rb
new file mode 100644
index 00000000000..6cb14ae569e
--- /dev/null
+++ b/spec/models/metrics/users_starred_dashboard_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Metrics::UsersStarredDashboard do
+ describe 'associations' do
+ it { is_expected.to belong_to(:project).inverse_of(:metrics_users_starred_dashboards) }
+ it { is_expected.to belong_to(:user).inverse_of(:metrics_users_starred_dashboards) }
+ end
+
+ describe 'validation' do
+ subject { build(:metrics_users_starred_dashboard) }
+
+ it { is_expected.to validate_presence_of(:user_id) }
+ it { is_expected.to validate_presence_of(:project_id) }
+ it { is_expected.to validate_presence_of(:dashboard_path) }
+ it { is_expected.to validate_length_of(:dashboard_path).is_at_most(255) }
+ it { is_expected.to validate_uniqueness_of(:dashboard_path).scoped_to(%i[user_id project_id]) }
+ end
+
+ context 'scopes' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:starred_dashboard_a) { create(:metrics_users_starred_dashboard, project: project, dashboard_path: 'path_a') }
+ let_it_be(:starred_dashboard_b) { create(:metrics_users_starred_dashboard, project: project, dashboard_path: 'path_b') }
+ let_it_be(:starred_dashboard_c) { create(:metrics_users_starred_dashboard, dashboard_path: 'path_b') }
+
+ describe '#for_project' do
+ it 'selects only starred dashboards belonging to project' do
+ expect(described_class.for_project(project)).to contain_exactly starred_dashboard_a, starred_dashboard_b
+ end
+ end
+
+ describe '#for_project_dashboard' do
+ it 'selects only starred dashboards belonging to project with given dashboard path' do
+ expect(described_class.for_project_dashboard(project, 'path_b')).to contain_exactly starred_dashboard_b
+ end
+ end
+ end
+end
diff --git a/spec/models/milestone_note_spec.rb b/spec/models/milestone_note_spec.rb
index 9e77ef91bb2..aad65cf0346 100644
--- a/spec/models/milestone_note_spec.rb
+++ b/spec/models/milestone_note_spec.rb
@@ -14,5 +14,15 @@ describe MilestoneNote do
it_behaves_like 'a system note', exclude_project: true do
let(:action) { 'milestone' }
end
+
+ context 'with a remove milestone event' do
+ let(:milestone) { create(:milestone) }
+ let(:event) { create(:resource_milestone_event, action: :remove, issue: noteable, milestone: milestone) }
+
+ it 'creates the expected note' do
+ expect(subject.note_html).to include('removed milestone')
+ expect(subject.note_html).not_to include('changed milestone to')
+ end
+ end
end
end
diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb
index ee4c35ebddd..e51108947a7 100644
--- a/spec/models/milestone_spec.rb
+++ b/spec/models/milestone_spec.rb
@@ -3,8 +3,10 @@
require 'spec_helper'
describe Milestone do
+ it_behaves_like 'a timebox', :milestone
+
describe 'MilestoneStruct#serializable_hash' do
- let(:predefined_milestone) { described_class::MilestoneStruct.new('Test Milestone', '#test', 1) }
+ let(:predefined_milestone) { described_class::TimeboxStruct.new('Test Milestone', '#test', 1) }
it 'presents the predefined milestone as a hash' do
expect(predefined_milestone.serializable_hash).to eq(
@@ -15,69 +17,11 @@ describe Milestone do
end
end
- describe 'modules' do
- context 'with a project' do
- it_behaves_like 'AtomicInternalId' do
- let(:internal_id_attribute) { :iid }
- let(:instance) { build(:milestone, project: build(:project), group: nil) }
- let(:scope) { :project }
- let(:scope_attrs) { { project: instance.project } }
- let(:usage) { :milestones }
- end
- end
-
- context 'with a group' do
- it_behaves_like 'AtomicInternalId' do
- let(:internal_id_attribute) { :iid }
- let(:instance) { build(:milestone, project: nil, group: build(:group)) }
- let(:scope) { :group }
- let(:scope_attrs) { { namespace: instance.group } }
- let(:usage) { :milestones }
- end
- end
- end
-
describe "Validation" do
before do
allow(subject).to receive(:set_iid).and_return(false)
end
- describe 'start_date' do
- it 'adds an error when start_date is greater then due_date' do
- milestone = build(:milestone, start_date: Date.tomorrow, due_date: Date.yesterday)
-
- expect(milestone).not_to be_valid
- expect(milestone.errors[:due_date]).to include("must be greater than start date")
- end
-
- it 'adds an error when start_date is greater than 9999-12-31' do
- milestone = build(:milestone, start_date: Date.new(10000, 1, 1))
-
- expect(milestone).not_to be_valid
- expect(milestone.errors[:start_date]).to include("date must not be after 9999-12-31")
- end
- end
-
- describe 'due_date' do
- it 'adds an error when due_date is greater than 9999-12-31' do
- milestone = build(:milestone, due_date: Date.new(10000, 1, 1))
-
- expect(milestone).not_to be_valid
- expect(milestone.errors[:due_date]).to include("date must not be after 9999-12-31")
- end
- end
-
- describe 'title' do
- it { is_expected.to validate_presence_of(:title) }
-
- it 'is invalid if title would be empty after sanitation' do
- milestone = build(:milestone, project: project, title: '<img src=x onerror=prompt(1)>')
-
- expect(milestone).not_to be_valid
- expect(milestone.errors[:title]).to include("can't be blank")
- end
- end
-
describe 'milestone_releases' do
let(:milestone) { build(:milestone, project: project) }
@@ -99,8 +43,6 @@ describe Milestone do
end
describe "Associations" do
- it { is_expected.to belong_to(:project) }
- it { is_expected.to have_many(:issues) }
it { is_expected.to have_many(:releases) }
it { is_expected.to have_many(:milestone_releases) }
end
@@ -110,87 +52,6 @@ describe Milestone do
let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) }
- describe "#title" do
- let(:milestone) { create(:milestone, title: "<b>foo & bar -> 2.2</b>") }
-
- it "sanitizes title" do
- expect(milestone.title).to eq("foo & bar -> 2.2")
- end
- end
-
- describe '#merge_requests_enabled?' do
- context "per project" do
- it "is true for projects with MRs enabled" do
- project = create(:project, :merge_requests_enabled)
- milestone = create(:milestone, project: project)
-
- expect(milestone.merge_requests_enabled?).to be(true)
- end
-
- it "is false for projects with MRs disabled" do
- project = create(:project, :repository_enabled, :merge_requests_disabled)
- milestone = create(:milestone, project: project)
-
- expect(milestone.merge_requests_enabled?).to be(false)
- end
-
- it "is false for projects with repository disabled" do
- project = create(:project, :repository_disabled)
- milestone = create(:milestone, project: project)
-
- expect(milestone.merge_requests_enabled?).to be(false)
- end
- end
-
- context "per group" do
- let(:group) { create(:group) }
- let(:milestone) { create(:milestone, group: group) }
-
- it "is always true for groups, for performance reasons" do
- expect(milestone.merge_requests_enabled?).to be(true)
- end
- end
- end
-
- describe "unique milestone title" do
- context "per project" do
- it "does not accept the same title in a project twice" do
- new_milestone = described_class.new(project: milestone.project, title: milestone.title)
- expect(new_milestone).not_to be_valid
- end
-
- it "accepts the same title in another project" do
- project = create(:project)
- new_milestone = described_class.new(project: project, title: milestone.title)
-
- expect(new_milestone).to be_valid
- end
- end
-
- context "per group" do
- let(:group) { create(:group) }
- let(:milestone) { create(:milestone, group: group) }
-
- before do
- project.update(group: group)
- end
-
- it "does not accept the same title in a group twice" do
- new_milestone = described_class.new(group: group, title: milestone.title)
-
- expect(new_milestone).not_to be_valid
- end
-
- it "does not accept the same title of a child project milestone" do
- create(:milestone, project: group.projects.first)
-
- new_milestone = described_class.new(group: group, title: milestone.title)
-
- expect(new_milestone).not_to be_valid
- end
- end
- end
-
describe '.predefined_id?' do
it 'returns true for a predefined Milestone ID' do
expect(Milestone.predefined_id?(described_class::Upcoming.id)).to be true
@@ -619,4 +480,22 @@ describe Milestone do
it { is_expected.not_to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab-foss/issues/123") }
it { is_expected.not_to match("gitlab-org/gitlab-ce/milestones/123") }
end
+
+ describe '#parent' do
+ context 'with group' do
+ it 'returns the expected parent' do
+ group = create(:group)
+
+ expect(build(:milestone, group: group).parent).to eq(group)
+ end
+ end
+
+ context 'with project' do
+ it 'returns the expected parent' do
+ project = create(:project)
+
+ expect(build(:milestone, project: project).parent).to eq(project)
+ end
+ end
+ end
end
diff --git a/spec/models/namespace/root_storage_size_spec.rb b/spec/models/namespace/root_storage_size_spec.rb
new file mode 100644
index 00000000000..a8048b7f637
--- /dev/null
+++ b/spec/models/namespace/root_storage_size_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Namespace::RootStorageSize, type: :model do
+ let(:namespace) { create(:namespace) }
+ let(:current_size) { 50.megabytes }
+ let(:limit) { 100 }
+ let(:model) { described_class.new(namespace) }
+ let(:create_statistics) { create(:namespace_root_storage_statistics, namespace: namespace, storage_size: current_size)}
+
+ before do
+ create_statistics
+
+ stub_application_setting(namespace_storage_size_limit: limit)
+ end
+
+ describe '#above_size_limit?' do
+ subject { model.above_size_limit? }
+
+ context 'when limit is 0' do
+ let(:limit) { 0 }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when below limit' do
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when above limit' do
+ let(:current_size) { 101.megabytes }
+
+ it { is_expected.to eq(true) }
+ end
+ end
+
+ describe '#usage_ratio' do
+ subject { model.usage_ratio }
+
+ it { is_expected.to eq(0.5) }
+
+ context 'when limit is 0' do
+ let(:limit) { 0 }
+
+ it { is_expected.to eq(0) }
+ end
+
+ context 'when there are no root_storage_statistics' do
+ let(:create_statistics) { nil }
+
+ it { is_expected.to eq(0) }
+ end
+ end
+
+ describe '#current_size' do
+ subject { model.current_size }
+
+ it { is_expected.to eq(current_size) }
+ end
+
+ describe '#limit' do
+ subject { model.limit }
+
+ it { is_expected.to eq(limit.megabytes) }
+ end
+end
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index 74ec74e0def..6dd295ca915 100644
--- a/spec/models/note_spec.rb
+++ b/spec/models/note_spec.rb
@@ -105,6 +105,38 @@ describe Note do
end
end
+ describe 'callbacks' do
+ describe '#notify_after_create' do
+ it 'calls #after_note_created on the noteable' do
+ note = build(:note)
+
+ expect(note).to receive(:notify_after_create).and_call_original
+ expect(note.noteable).to receive(:after_note_created).with(note)
+
+ note.save!
+ end
+ end
+
+ describe '#notify_after_destroy' do
+ it 'calls #after_note_destroyed on the noteable' do
+ note = create(:note)
+
+ expect(note).to receive(:notify_after_destroy).and_call_original
+ expect(note.noteable).to receive(:after_note_destroyed).with(note)
+
+ note.destroy
+ end
+
+ it 'does not error if noteable is nil' do
+ note = create(:note)
+
+ expect(note).to receive(:notify_after_destroy).and_call_original
+ expect(note).to receive(:noteable).at_least(:once).and_return(nil)
+ expect { note.destroy }.not_to raise_error
+ end
+ end
+ end
+
describe "Commit notes" do
before do
allow(Gitlab::Git::KeepAround).to receive(:execute).and_call_original
@@ -751,6 +783,14 @@ describe Note do
end
end
+ describe '#for_design' do
+ it 'is true when the noteable is a design' do
+ note = build(:note, noteable: build(:design))
+
+ expect(note).to be_for_design
+ end
+ end
+
describe '#to_ability_name' do
it 'returns note' do
expect(build(:note).to_ability_name).to eq('note')
diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb
index fa2648979e9..54747ddf525 100644
--- a/spec/models/pages_domain_spec.rb
+++ b/spec/models/pages_domain_spec.rb
@@ -620,7 +620,11 @@ describe PagesDomain do
create(:pages_domain, :letsencrypt, :with_expired_certificate)
end
- it 'contains only domains needing verification' do
+ let!(:domain_with_failed_auto_ssl) do
+ create(:pages_domain, auto_ssl_enabled: true, auto_ssl_failed: true)
+ end
+
+ it 'contains only domains needing ssl renewal' do
is_expected.to(
contain_exactly(
domain_with_user_provided_certificate_and_auto_ssl,
diff --git a/spec/models/performance_monitoring/prometheus_dashboard_spec.rb b/spec/models/performance_monitoring/prometheus_dashboard_spec.rb
new file mode 100644
index 00000000000..e6fc03a0fb6
--- /dev/null
+++ b/spec/models/performance_monitoring/prometheus_dashboard_spec.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe PerformanceMonitoring::PrometheusDashboard do
+ let(:json_content) do
+ {
+ "dashboard" => "Dashboard Title",
+ "templating" => {
+ "variables" => {
+ "variable1" => %w(value1 value2 value3)
+ }
+ },
+ "panel_groups" => [{
+ "group" => "Group Title",
+ "panels" => [{
+ "type" => "area-chart",
+ "title" => "Chart Title",
+ "y_label" => "Y-Axis",
+ "metrics" => [{
+ "id" => "metric_of_ages",
+ "unit" => "count",
+ "label" => "Metric of Ages",
+ "query_range" => "http_requests_total"
+ }]
+ }]
+ }]
+ }
+ end
+
+ describe '.from_json' do
+ subject { described_class.from_json(json_content) }
+
+ it 'creates a PrometheusDashboard object' do
+ expect(subject).to be_a PerformanceMonitoring::PrometheusDashboard
+ expect(subject.dashboard).to eq(json_content['dashboard'])
+ expect(subject.panel_groups).to all(be_a PerformanceMonitoring::PrometheusPanelGroup)
+ end
+
+ describe 'validations' do
+ context 'when dashboard is missing' do
+ before do
+ json_content['dashboard'] = nil
+ end
+
+ subject { described_class.from_json(json_content) }
+
+ it { expect { subject }.to raise_error(ActiveModel::ValidationError) }
+ end
+
+ context 'when panel groups are missing' do
+ before do
+ json_content['panel_groups'] = []
+ end
+
+ subject { described_class.from_json(json_content) }
+
+ it { expect { subject }.to raise_error(ActiveModel::ValidationError) }
+ end
+ end
+ end
+
+ describe '.find_for' do
+ let(:project) { build_stubbed(:project) }
+ let(:user) { build_stubbed(:user) }
+ let(:environment) { build_stubbed(:environment) }
+ let(:path) { ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH }
+
+ context 'dashboard has been found' do
+ it 'uses dashboard finder to find and load dashboard data and returns dashboard instance', :aggregate_failures do
+ expect(Gitlab::Metrics::Dashboard::Finder).to receive(:find).with(project, user, environment: environment, dashboard_path: path).and_return(status: :success, dashboard: json_content)
+
+ dashboard_instance = described_class.find_for(project: project, user: user, path: path, options: { environment: environment })
+
+ expect(dashboard_instance).to be_instance_of described_class
+ expect(dashboard_instance.environment).to be environment
+ expect(dashboard_instance.path).to be path
+ end
+ end
+
+ context 'dashboard has NOT been found' do
+ it 'returns nil' do
+ allow(Gitlab::Metrics::Dashboard::Finder).to receive(:find).and_return(status: :error)
+
+ dashboard_instance = described_class.find_for(project: project, user: user, path: path, options: { environment: environment })
+
+ expect(dashboard_instance).to be_nil
+ end
+ end
+ end
+
+ describe '#to_yaml' do
+ subject { prometheus_dashboard.to_yaml }
+
+ let(:prometheus_dashboard) { described_class.from_json(json_content) }
+ let(:expected_yaml) do
+ "---\npanel_groups:\n- panels:\n - metrics:\n - id: metric_of_ages\n unit: count\n label: Metric of Ages\n query: \n query_range: http_requests_total\n type: area-chart\n title: Chart Title\n y_label: Y-Axis\n weight: \n group: Group Title\n priority: \ndashboard: Dashboard Title\n"
+ end
+
+ it { is_expected.to eq(expected_yaml) }
+ end
+end
diff --git a/spec/models/performance_monitoring/prometheus_metric_spec.rb b/spec/models/performance_monitoring/prometheus_metric_spec.rb
new file mode 100644
index 00000000000..08288e5d993
--- /dev/null
+++ b/spec/models/performance_monitoring/prometheus_metric_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe PerformanceMonitoring::PrometheusMetric do
+ let(:json_content) do
+ {
+ "id" => "metric_of_ages",
+ "unit" => "count",
+ "label" => "Metric of Ages",
+ "query_range" => "http_requests_total"
+ }
+ end
+
+ describe '.from_json' do
+ subject { described_class.from_json(json_content) }
+
+ it 'creates a PrometheusMetric object' do
+ expect(subject).to be_a PerformanceMonitoring::PrometheusMetric
+ expect(subject.id).to eq(json_content['id'])
+ expect(subject.unit).to eq(json_content['unit'])
+ expect(subject.label).to eq(json_content['label'])
+ expect(subject.query_range).to eq(json_content['query_range'])
+ end
+
+ describe 'validations' do
+ context 'when unit is missing' do
+ before do
+ json_content['unit'] = nil
+ end
+
+ subject { described_class.from_json(json_content) }
+
+ it { expect { subject }.to raise_error(ActiveModel::ValidationError) }
+ end
+
+ context 'when query and query_range is missing' do
+ before do
+ json_content['query_range'] = nil
+ end
+
+ subject { described_class.from_json(json_content) }
+
+ it { expect { subject }.to raise_error(ActiveModel::ValidationError) }
+ end
+
+ context 'when query_range is missing but query is available' do
+ before do
+ json_content['query_range'] = nil
+ json_content['query'] = 'http_requests_total'
+ end
+
+ subject { described_class.from_json(json_content) }
+
+ it { is_expected.to be_valid }
+ end
+ end
+ end
+end
diff --git a/spec/models/performance_monitoring/prometheus_panel_group_spec.rb b/spec/models/performance_monitoring/prometheus_panel_group_spec.rb
new file mode 100644
index 00000000000..2447bb5df94
--- /dev/null
+++ b/spec/models/performance_monitoring/prometheus_panel_group_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe PerformanceMonitoring::PrometheusPanelGroup do
+ let(:json_content) do
+ {
+ "group" => "Group Title",
+ "panels" => [{
+ "type" => "area-chart",
+ "title" => "Chart Title",
+ "y_label" => "Y-Axis",
+ "metrics" => [{
+ "id" => "metric_of_ages",
+ "unit" => "count",
+ "label" => "Metric of Ages",
+ "query_range" => "http_requests_total"
+ }]
+ }]
+ }
+ end
+
+ describe '.from_json' do
+ subject { described_class.from_json(json_content) }
+
+ it 'creates a PrometheusPanelGroup object' do
+ expect(subject).to be_a PerformanceMonitoring::PrometheusPanelGroup
+ expect(subject.group).to eq(json_content['group'])
+ expect(subject.panels).to all(be_a PerformanceMonitoring::PrometheusPanel)
+ end
+
+ describe 'validations' do
+ context 'when group is missing' do
+ before do
+ json_content['group'] = nil
+ end
+
+ subject { described_class.from_json(json_content) }
+
+ it { expect { subject }.to raise_error(ActiveModel::ValidationError) }
+ end
+
+ context 'when panels are missing' do
+ before do
+ json_content['panels'] = []
+ end
+
+ subject { described_class.from_json(json_content) }
+
+ it { expect { subject }.to raise_error(ActiveModel::ValidationError) }
+ end
+ end
+ end
+end
diff --git a/spec/models/performance_monitoring/prometheus_panel_spec.rb b/spec/models/performance_monitoring/prometheus_panel_spec.rb
new file mode 100644
index 00000000000..f5e04ec91e2
--- /dev/null
+++ b/spec/models/performance_monitoring/prometheus_panel_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe PerformanceMonitoring::PrometheusPanel do
+ let(:json_content) do
+ {
+ "max_value" => 1,
+ "type" => "area-chart",
+ "title" => "Chart Title",
+ "y_label" => "Y-Axis",
+ "weight" => 1,
+ "metrics" => [{
+ "id" => "metric_of_ages",
+ "unit" => "count",
+ "label" => "Metric of Ages",
+ "query_range" => "http_requests_total"
+ }]
+ }
+ end
+
+ describe '#new' do
+ it 'accepts old schema format' do
+ expect { described_class.new(json_content) }.not_to raise_error
+ end
+
+ it 'accepts new schema format' do
+ expect { described_class.new(json_content.merge("y_axis" => { "precision" => 0 })) }.not_to raise_error
+ end
+ end
+
+ describe '.from_json' do
+ subject { described_class.from_json(json_content) }
+
+ it 'creates a PrometheusPanelGroup object' do
+ expect(subject).to be_a PerformanceMonitoring::PrometheusPanel
+ expect(subject.type).to eq(json_content['type'])
+ expect(subject.title).to eq(json_content['title'])
+ expect(subject.y_label).to eq(json_content['y_label'])
+ expect(subject.weight).to eq(json_content['weight'])
+ expect(subject.metrics).to all(be_a PerformanceMonitoring::PrometheusMetric)
+ end
+
+ describe 'validations' do
+ context 'when title is missing' do
+ before do
+ json_content['title'] = nil
+ end
+
+ subject { described_class.from_json(json_content) }
+
+ it { expect { subject }.to raise_error(ActiveModel::ValidationError) }
+ end
+
+ context 'when metrics are missing' do
+ before do
+ json_content['metrics'] = []
+ end
+
+ subject { described_class.from_json(json_content) }
+
+ it { expect { subject }.to raise_error(ActiveModel::ValidationError) }
+ end
+ end
+ end
+
+ describe '.id' do
+ it 'returns hexdigest of group_title, type and title as the panel id' do
+ group_title = 'Business Group'
+ panel_type = 'area-chart'
+ panel_title = 'New feature requests made'
+
+ expect(Digest::SHA2).to receive(:hexdigest).with("#{group_title}#{panel_type}#{panel_title}").and_return('hexdigest')
+ expect(described_class.new(title: panel_title, type: panel_type).id(group_title)).to eql 'hexdigest'
+ end
+ end
+end
diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb
index b16d1f58be5..596b11613b3 100644
--- a/spec/models/personal_access_token_spec.rb
+++ b/spec/models/personal_access_token_spec.rb
@@ -179,4 +179,27 @@ describe PersonalAccessToken do
end
end
end
+
+ describe '.simple_sorts' do
+ it 'includes overriden keys' do
+ expect(described_class.simple_sorts.keys).to include(*%w(expires_at_asc expires_at_desc))
+ end
+ end
+
+ describe 'ordering by expires_at' do
+ let_it_be(:earlier_token) { create(:personal_access_token, expires_at: 2.days.ago) }
+ let_it_be(:later_token) { create(:personal_access_token, expires_at: 1.day.ago) }
+
+ describe '.order_expires_at_asc' do
+ it 'returns ordered list in asc order of expiry date' do
+ expect(described_class.order_expires_at_asc).to match [earlier_token, later_token]
+ end
+ end
+
+ describe '.order_expires_at_desc' do
+ it 'returns ordered list in desc order of expiry date' do
+ expect(described_class.order_expires_at_desc).to match [later_token, earlier_token]
+ end
+ end
+ end
end
diff --git a/spec/models/personal_snippet_spec.rb b/spec/models/personal_snippet_spec.rb
index a055f107e33..fb96d6e8bc3 100644
--- a/spec/models/personal_snippet_spec.rb
+++ b/spec/models/personal_snippet_spec.rb
@@ -22,5 +22,6 @@ describe PersonalSnippet do
let(:stubbed_container) { build_stubbed(:personal_snippet) }
let(:expected_full_path) { "@snippets/#{container.id}" }
let(:expected_web_url_path) { "snippets/#{container.id}" }
+ let(:expected_repo_url_path) { expected_web_url_path }
end
end
diff --git a/spec/models/plan_limits_spec.rb b/spec/models/plan_limits_spec.rb
new file mode 100644
index 00000000000..1366f088623
--- /dev/null
+++ b/spec/models/plan_limits_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe PlanLimits do
+ let(:plan_limits) { create(:plan_limits) }
+ let(:model) { ProjectHook }
+ let(:count) { model.count }
+
+ before do
+ create(:project_hook)
+ end
+
+ context 'without plan limits configured' do
+ describe '#exceeded?' do
+ it 'does not exceed any relation offset' do
+ expect(plan_limits.exceeded?(:project_hooks, model)).to be false
+ expect(plan_limits.exceeded?(:project_hooks, count)).to be false
+ end
+ end
+ end
+
+ context 'with plan limits configured' do
+ before do
+ plan_limits.update!(project_hooks: 2)
+ end
+
+ describe '#exceeded?' do
+ it 'does not exceed the relation offset' do
+ expect(plan_limits.exceeded?(:project_hooks, model)).to be false
+ expect(plan_limits.exceeded?(:project_hooks, count)).to be false
+ end
+ end
+
+ context 'with boundary values' do
+ before do
+ create(:project_hook)
+ end
+
+ describe '#exceeded?' do
+ it 'does exceed the relation offset' do
+ expect(plan_limits.exceeded?(:project_hooks, model)).to be true
+ expect(plan_limits.exceeded?(:project_hooks, count)).to be true
+ end
+ end
+ end
+ end
+
+ context 'validates default values' do
+ let(:columns_with_zero) do
+ %w[
+ ci_active_pipelines
+ ci_pipeline_size
+ ci_active_jobs
+ ]
+ end
+
+ it "has positive values for enabled limits" do
+ attributes = plan_limits.attributes
+ attributes = attributes.except(described_class.primary_key)
+ attributes = attributes.except(described_class.reflections.values.map(&:foreign_key))
+ attributes = attributes.except(*columns_with_zero)
+
+ expect(attributes).to all(include(be_positive))
+ end
+
+ it "has zero values for disabled limits" do
+ attributes = plan_limits.attributes
+ attributes = attributes.slice(*columns_with_zero)
+
+ expect(attributes).to all(include(be_zero))
+ end
+ end
+end
diff --git a/spec/models/plan_spec.rb b/spec/models/plan_spec.rb
new file mode 100644
index 00000000000..3f3b8046232
--- /dev/null
+++ b/spec/models/plan_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Plan do
+ describe '#default?' do
+ subject { plan.default? }
+
+ Plan.default_plans.each do |plan|
+ context "when '#{plan}'" do
+ let(:plan) { build("#{plan}_plan".to_sym) }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+ end
+end
diff --git a/spec/models/project_ci_cd_setting_spec.rb b/spec/models/project_ci_cd_setting_spec.rb
index 312cbbb0948..86115a61aa7 100644
--- a/spec/models/project_ci_cd_setting_spec.rb
+++ b/spec/models/project_ci_cd_setting_spec.rb
@@ -54,17 +54,5 @@ describe ProjectCiCdSetting do
expect(project.reload.ci_cd_settings.default_git_depth).to eq(0)
end
-
- context 'when feature flag :ci_set_project_default_git_depth is disabled' do
- let(:project) { create(:project) }
-
- before do
- stub_feature_flags(ci_set_project_default_git_depth: { enabled: false } )
- end
-
- it 'does not set default value for new records' do
- expect(project.ci_cd_settings.default_git_depth).to eq(nil)
- end
- end
end
end
diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb
index 38fba5ea071..e072cc21b38 100644
--- a/spec/models/project_feature_spec.rb
+++ b/spec/models/project_feature_spec.rb
@@ -31,27 +31,30 @@ describe ProjectFeature do
context 'when features are disabled' do
it "returns false" do
+ update_all_project_features(project, features, ProjectFeature::DISABLED)
+
features.each do |feature|
- project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::DISABLED)
- expect(project.feature_available?(:issues, user)).to eq(false)
+ expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
end
end
end
context 'when features are enabled only for team members' do
it "returns false when user is not a team member" do
+ update_all_project_features(project, features, ProjectFeature::PRIVATE)
+
features.each do |feature|
- project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE)
- expect(project.feature_available?(:issues, user)).to eq(false)
+ expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
end
end
it "returns true when user is a team member" do
project.add_developer(user)
+ update_all_project_features(project, features, ProjectFeature::PRIVATE)
+
features.each do |feature|
- project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE)
- expect(project.feature_available?(:issues, user)).to eq(true)
+ expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed"
end
end
@@ -60,27 +63,41 @@ describe ProjectFeature do
project = create(:project, namespace: group)
group.add_developer(user)
+ update_all_project_features(project, features, ProjectFeature::PRIVATE)
+
features.each do |feature|
- project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE)
- expect(project.feature_available?(:issues, user)).to eq(true)
+ expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed"
end
end
- it "returns true if user is an admin" do
- user.update_attribute(:admin, true)
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it "returns true if user is an admin" do
+ user.update_attribute(:admin, true)
- features.each do |feature|
- project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE)
- expect(project.feature_available?(:issues, user)).to eq(true)
+ update_all_project_features(project, features, ProjectFeature::PRIVATE)
+
+ features.each do |feature|
+ expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed"
+ end
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it "returns false when user is an admin" do
+ user.update_attribute(:admin, true)
+
+ update_all_project_features(project, features, ProjectFeature::PRIVATE)
+
+ features.each do |feature|
+ expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
+ end
end
end
end
context 'when feature is enabled for everyone' do
it "returns true" do
- features.each do |feature|
- expect(project.feature_available?(:issues, user)).to eq(true)
- end
+ expect(project.feature_available?(:issues, user)).to eq(true)
end
end
@@ -117,7 +134,7 @@ describe ProjectFeature do
features.each do |feature|
field = "#{feature}_access_level".to_sym
project_feature.update_attribute(field, ProjectFeature::ENABLED)
- expect(project_feature.valid?).to be_falsy
+ expect(project_feature.valid?).to be_falsy, "#{field} failed"
end
end
end
@@ -131,7 +148,7 @@ describe ProjectFeature do
field = "#{feature}_access_level".to_sym
project_feature.update_attribute(field, ProjectFeature::PUBLIC)
- expect(project_feature.valid?).to be_falsy
+ expect(project_feature.valid?).to be_falsy, "#{field} failed"
end
end
end
@@ -140,22 +157,24 @@ describe ProjectFeature do
let(:features) { %w(wiki builds merge_requests) }
it "returns false when feature is disabled" do
+ update_all_project_features(project, features, ProjectFeature::DISABLED)
+
features.each do |feature|
- project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::DISABLED)
- expect(project.public_send("#{feature}_enabled?")).to eq(false)
+ expect(project.public_send("#{feature}_enabled?")).to eq(false), "#{feature} failed"
end
end
it "returns true when feature is enabled only for team members" do
+ update_all_project_features(project, features, ProjectFeature::PRIVATE)
+
features.each do |feature|
- project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE)
- expect(project.public_send("#{feature}_enabled?")).to eq(true)
+ expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed"
end
end
it "returns true when feature is enabled for everyone" do
features.each do |feature|
- expect(project.public_send("#{feature}_enabled?")).to eq(true)
+ expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed"
end
end
end
@@ -198,7 +217,7 @@ describe ProjectFeature do
end
describe '#public_pages?' do
- it 'returns true if Pages access controll is not enabled' do
+ it 'returns true if Pages access control is not enabled' do
stub_config(pages: { access_control: false })
project_feature = described_class.new(pages_access_level: described_class::PRIVATE)
@@ -281,7 +300,7 @@ describe ProjectFeature do
it 'raises error if feature is invalid' do
expect do
described_class.required_minimum_access_level(:foos)
- end.to raise_error
+ end.to raise_error(ArgumentError)
end
end
@@ -294,4 +313,9 @@ describe ProjectFeature do
expect(described_class.required_minimum_access_level_for_private_project(:issues)).to eq(Gitlab::Access::GUEST)
end
end
+
+ def update_all_project_features(project, features, value)
+ project_feature_attributes = features.map { |f| ["#{f}_access_level", value] }.to_h
+ project.project_feature.update(project_feature_attributes)
+ end
end
diff --git a/spec/models/project_repository_storage_move_spec.rb b/spec/models/project_repository_storage_move_spec.rb
new file mode 100644
index 00000000000..146fc13bee0
--- /dev/null
+++ b/spec/models/project_repository_storage_move_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ProjectRepositoryStorageMove, type: :model do
+ describe 'associations' do
+ it { is_expected.to belong_to(:project) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_presence_of(:state) }
+ it { is_expected.to validate_presence_of(:source_storage_name) }
+ it { is_expected.to validate_presence_of(:destination_storage_name) }
+
+ context 'source_storage_name inclusion' do
+ subject { build(:project_repository_storage_move, source_storage_name: 'missing') }
+
+ it "does not allow repository storages that don't match a label in the configuration" do
+ expect(subject).not_to be_valid
+ expect(subject.errors[:source_storage_name].first).to match(/is not included in the list/)
+ end
+ end
+
+ context 'destination_storage_name inclusion' do
+ subject { build(:project_repository_storage_move, destination_storage_name: 'missing') }
+
+ it "does not allow repository storages that don't match a label in the configuration" do
+ expect(subject).not_to be_valid
+ expect(subject.errors[:destination_storage_name].first).to match(/is not included in the list/)
+ end
+ end
+ end
+
+ describe 'state transitions' do
+ using RSpec::Parameterized::TableSyntax
+
+ context 'when in the default state' do
+ subject(:storage_move) { create(:project_repository_storage_move, project: project, destination_storage_name: 'test_second_storage') }
+
+ let(:project) { create(:project) }
+
+ before do
+ stub_storage_settings('test_second_storage' => { 'path' => 'tmp/tests/extra_storage' })
+ end
+
+ context 'and transits to scheduled' do
+ it 'triggers ProjectUpdateRepositoryStorageWorker' do
+ expect(ProjectUpdateRepositoryStorageWorker).to receive(:perform_async).with(project.id, 'test_second_storage', storage_move.id)
+
+ storage_move.schedule!
+ end
+ end
+
+ context 'and transits to started' do
+ it 'does not allow the transition' do
+ expect { storage_move.start! }
+ .to raise_error(StateMachines::InvalidTransition)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/project_services/chat_message/pipeline_message_spec.rb b/spec/models/project_services/chat_message/pipeline_message_spec.rb
index e99148d1d1f..7c3e48f572a 100644
--- a/spec/models/project_services/chat_message/pipeline_message_spec.rb
+++ b/spec/models/project_services/chat_message/pipeline_message_spec.rb
@@ -55,475 +55,324 @@ describe ChatMessage::PipelineMessage do
allow(Gitlab::UrlBuilder).to receive(:build).with(args[:user]).and_return("http://example.gitlab.com/hacker")
end
- context 'when the fancy_pipeline_slack_notifications feature flag is disabled' do
- before do
- stub_feature_flags(fancy_pipeline_slack_notifications: false)
- end
+ it 'returns an empty pretext' do
+ expect(subject.pretext).to be_empty
+ end
+
+ it "returns the pipeline summary in the activity's title" do
+ expect(subject.activity[:title]).to eq(
+ "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
+ " of branch [develop](http://example.gitlab.com/commits/develop)" \
+ " by The Hacker (hacker) has passed"
+ )
+ end
- it 'returns an empty pretext' do
- expect(subject.pretext).to be_empty
+ context "when the pipeline failed" do
+ before do
+ args[:object_attributes][:status] = 'failed'
end
- it "returns the pipeline summary in the activity's title" do
+ it "returns the summary with a 'failed' status" do
expect(subject.activity[:title]).to eq(
"Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
" of branch [develop](http://example.gitlab.com/commits/develop)" \
- " by The Hacker (hacker) passed"
+ " by The Hacker (hacker) has failed"
)
end
+ end
- context "when the pipeline failed" do
- before do
- args[:object_attributes][:status] = 'failed'
- end
-
- it "returns the summary with a 'failed' status" do
- expect(subject.activity[:title]).to eq(
- "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
- " of branch [develop](http://example.gitlab.com/commits/develop)" \
- " by The Hacker (hacker) failed"
- )
- end
- end
-
- context 'when no user is provided because the pipeline was triggered by the API' do
- before do
- args[:user] = nil
- end
-
- it "returns the summary with 'API' as the username" do
- expect(subject.activity[:title]).to eq(
- "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
- " of branch [develop](http://example.gitlab.com/commits/develop)" \
- " by API passed"
- )
- end
- end
-
- it "returns a link to the project in the activity's subtitle" do
- expect(subject.activity[:subtitle]).to eq("in [project_name](http://example.gitlab.com)")
- end
-
- it "returns the build duration in the activity's text property" do
- expect(subject.activity[:text]).to eq("in 02:00:10")
- end
-
- it "returns the user's avatar image URL in the activity's image property" do
- expect(subject.activity[:image]).to eq("http://example.com/avatar")
- end
-
- context 'when the user does not have an avatar' do
- before do
- args[:user][:avatar_url] = nil
- end
-
- it "returns an empty string in the activity's image property" do
- expect(subject.activity[:image]).to be_empty
- end
+ context "when the pipeline passed with warnings" do
+ before do
+ args[:object_attributes][:detailed_status] = 'passed with warnings'
end
- it "returns the pipeline summary as the attachment's text property" do
- expect(subject.attachments.first[:text]).to eq(
- "<http://example.gitlab.com|project_name>:" \
- " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \
- " of branch <http://example.gitlab.com/commits/develop|develop>" \
- " by The Hacker (hacker) passed in 02:00:10"
+ it "returns the summary with a 'passed with warnings' status" do
+ expect(subject.activity[:title]).to eq(
+ "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
+ " of branch [develop](http://example.gitlab.com/commits/develop)" \
+ " by The Hacker (hacker) has passed with warnings"
)
end
-
- it "returns 'good' as the attachment's color property" do
- expect(subject.attachments.first[:color]).to eq('good')
- end
-
- context "when the pipeline failed" do
- before do
- args[:object_attributes][:status] = 'failed'
- end
-
- it "returns 'danger' as the attachment's color property" do
- expect(subject.attachments.first[:color]).to eq('danger')
- end
- end
-
- context 'when rendering markdown' do
- before do
- args[:markdown] = true
- end
-
- it 'returns the pipeline summary as the attachments in markdown format' do
- expect(subject.attachments).to eq(
- "[project_name](http://example.gitlab.com):" \
- " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
- " of branch [develop](http://example.gitlab.com/commits/develop)" \
- " by The Hacker (hacker) passed in 02:00:10"
- )
- end
- end
-
- context 'when ref type is tag' do
- before do
- args[:object_attributes][:tag] = true
- args[:object_attributes][:ref] = 'new_tag'
- end
-
- it "returns the pipeline summary in the activity's title" do
- expect(subject.activity[:title]).to eq(
- "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
- " of tag [new_tag](http://example.gitlab.com/-/tags/new_tag)" \
- " by The Hacker (hacker) passed"
- )
- end
-
- it "returns the pipeline summary as the attachment's text property" do
- expect(subject.attachments.first[:text]).to eq(
- "<http://example.gitlab.com|project_name>:" \
- " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \
- " of tag <http://example.gitlab.com/-/tags/new_tag|new_tag>" \
- " by The Hacker (hacker) passed in 02:00:10"
- )
- end
-
- context 'when rendering markdown' do
- before do
- args[:markdown] = true
- end
-
- it 'returns the pipeline summary as the attachments in markdown format' do
- expect(subject.attachments).to eq(
- "[project_name](http://example.gitlab.com):" \
- " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
- " of tag [new_tag](http://example.gitlab.com/-/tags/new_tag)" \
- " by The Hacker (hacker) passed in 02:00:10"
- )
- end
- end
- end
end
- context 'when the fancy_pipeline_slack_notifications feature flag is enabled' do
+ context 'when no user is provided because the pipeline was triggered by the API' do
before do
- stub_feature_flags(fancy_pipeline_slack_notifications: true)
- end
-
- it 'returns an empty pretext' do
- expect(subject.pretext).to be_empty
+ args[:user] = nil
end
- it "returns the pipeline summary in the activity's title" do
+ it "returns the summary with 'API' as the username" do
expect(subject.activity[:title]).to eq(
"Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
" of branch [develop](http://example.gitlab.com/commits/develop)" \
- " by The Hacker (hacker) has passed"
+ " by API has passed"
)
end
+ end
- context "when the pipeline failed" do
- before do
- args[:object_attributes][:status] = 'failed'
- end
+ it "returns a link to the project in the activity's subtitle" do
+ expect(subject.activity[:subtitle]).to eq("in [project_name](http://example.gitlab.com)")
+ end
- it "returns the summary with a 'failed' status" do
- expect(subject.activity[:title]).to eq(
- "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
- " of branch [develop](http://example.gitlab.com/commits/develop)" \
- " by The Hacker (hacker) has failed"
- )
- end
- end
+ it "returns the build duration in the activity's text property" do
+ expect(subject.activity[:text]).to eq("in 02:00:10")
+ end
- context "when the pipeline passed with warnings" do
- before do
- args[:object_attributes][:detailed_status] = 'passed with warnings'
- end
+ it "returns the user's avatar image URL in the activity's image property" do
+ expect(subject.activity[:image]).to eq("http://example.com/avatar")
+ end
- it "returns the summary with a 'passed with warnings' status" do
- expect(subject.activity[:title]).to eq(
- "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
- " of branch [develop](http://example.gitlab.com/commits/develop)" \
- " by The Hacker (hacker) has passed with warnings"
- )
- end
+ context 'when the user does not have an avatar' do
+ before do
+ args[:user][:avatar_url] = nil
end
- context 'when no user is provided because the pipeline was triggered by the API' do
- before do
- args[:user] = nil
- end
-
- it "returns the summary with 'API' as the username" do
- expect(subject.activity[:title]).to eq(
- "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
- " of branch [develop](http://example.gitlab.com/commits/develop)" \
- " by API has passed"
- )
- end
+ it "returns an empty string in the activity's image property" do
+ expect(subject.activity[:image]).to be_empty
end
+ end
- it "returns a link to the project in the activity's subtitle" do
- expect(subject.activity[:subtitle]).to eq("in [project_name](http://example.gitlab.com)")
- end
+ it "returns the pipeline summary as the attachment's fallback property" do
+ expect(subject.attachments.first[:fallback]).to eq(
+ "<http://example.gitlab.com|project_name>:" \
+ " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \
+ " of branch <http://example.gitlab.com/commits/develop|develop>" \
+ " by The Hacker (hacker) has passed in 02:00:10"
+ )
+ end
- it "returns the build duration in the activity's text property" do
- expect(subject.activity[:text]).to eq("in 02:00:10")
- end
+ it "returns 'good' as the attachment's color property" do
+ expect(subject.attachments.first[:color]).to eq('good')
+ end
- it "returns the user's avatar image URL in the activity's image property" do
- expect(subject.activity[:image]).to eq("http://example.com/avatar")
+ context "when the pipeline failed" do
+ before do
+ args[:object_attributes][:status] = 'failed'
end
- context 'when the user does not have an avatar' do
- before do
- args[:user][:avatar_url] = nil
- end
-
- it "returns an empty string in the activity's image property" do
- expect(subject.activity[:image]).to be_empty
- end
+ it "returns 'danger' as the attachment's color property" do
+ expect(subject.attachments.first[:color]).to eq('danger')
end
+ end
- it "returns the pipeline summary as the attachment's fallback property" do
- expect(subject.attachments.first[:fallback]).to eq(
- "<http://example.gitlab.com|project_name>:" \
- " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \
- " of branch <http://example.gitlab.com/commits/develop|develop>" \
- " by The Hacker (hacker) has passed in 02:00:10"
- )
+ context "when the pipeline passed with warnings" do
+ before do
+ args[:object_attributes][:detailed_status] = 'passed with warnings'
end
- it "returns 'good' as the attachment's color property" do
- expect(subject.attachments.first[:color]).to eq('good')
+ it "returns 'warning' as the attachment's color property" do
+ expect(subject.attachments.first[:color]).to eq('warning')
end
+ end
- context "when the pipeline failed" do
- before do
- args[:object_attributes][:status] = 'failed'
- end
+ it "returns the committer's name and username as the attachment's author_name property" do
+ expect(subject.attachments.first[:author_name]).to eq('The Hacker (hacker)')
+ end
- it "returns 'danger' as the attachment's color property" do
- expect(subject.attachments.first[:color]).to eq('danger')
- end
- end
+ it "returns the committer's avatar URL as the attachment's author_icon property" do
+ expect(subject.attachments.first[:author_icon]).to eq('http://example.com/avatar')
+ end
- context "when the pipeline passed with warnings" do
- before do
- args[:object_attributes][:detailed_status] = 'passed with warnings'
- end
+ it "returns the committer's GitLab profile URL as the attachment's author_link property" do
+ expect(subject.attachments.first[:author_link]).to eq('http://example.gitlab.com/hacker')
+ end
- it "returns 'warning' as the attachment's color property" do
- expect(subject.attachments.first[:color]).to eq('warning')
- end
+ context 'when no user is provided because the pipeline was triggered by the API' do
+ before do
+ args[:user] = nil
end
it "returns the committer's name and username as the attachment's author_name property" do
- expect(subject.attachments.first[:author_name]).to eq('The Hacker (hacker)')
+ expect(subject.attachments.first[:author_name]).to eq('API')
end
- it "returns the committer's avatar URL as the attachment's author_icon property" do
- expect(subject.attachments.first[:author_icon]).to eq('http://example.com/avatar')
+ it "returns nil as the attachment's author_icon property" do
+ expect(subject.attachments.first[:author_icon]).to be_nil
end
- it "returns the committer's GitLab profile URL as the attachment's author_link property" do
- expect(subject.attachments.first[:author_link]).to eq('http://example.gitlab.com/hacker')
+ it "returns nil as the attachment's author_link property" do
+ expect(subject.attachments.first[:author_link]).to be_nil
end
+ end
- context 'when no user is provided because the pipeline was triggered by the API' do
- before do
- args[:user] = nil
- end
+ it "returns the pipeline ID, status, and duration as the attachment's title property" do
+ expect(subject.attachments.first[:title]).to eq("Pipeline #123 has passed in 02:00:10")
+ end
- it "returns the committer's name and username as the attachment's author_name property" do
- expect(subject.attachments.first[:author_name]).to eq('API')
- end
+ it "returns the pipeline URL as the attachment's title_link property" do
+ expect(subject.attachments.first[:title_link]).to eq("http://example.gitlab.com/pipelines/123")
+ end
- it "returns nil as the attachment's author_icon property" do
- expect(subject.attachments.first[:author_icon]).to be_nil
- end
+ it "returns two attachment fields" do
+ expect(subject.attachments.first[:fields].count).to eq(2)
+ end
- it "returns nil as the attachment's author_link property" do
- expect(subject.attachments.first[:author_link]).to be_nil
- end
- end
+ it "returns the commit message as the attachment's second field property" do
+ expect(subject.attachments.first[:fields][0]).to eq({
+ title: "Branch",
+ value: "<http://example.gitlab.com/commits/develop|develop>",
+ short: true
+ })
+ end
- it "returns the pipeline ID, status, and duration as the attachment's title property" do
- expect(subject.attachments.first[:title]).to eq("Pipeline #123 has passed in 02:00:10")
- end
+ it "returns the ref name and link as the attachment's second field property" do
+ expect(subject.attachments.first[:fields][1]).to eq({
+ title: "Commit",
+ value: "<http://example.com/commit|A test commit message>",
+ short: true
+ })
+ end
- it "returns the pipeline URL as the attachment's title_link property" do
- expect(subject.attachments.first[:title_link]).to eq("http://example.gitlab.com/pipelines/123")
+ context "when a job in the pipeline fails" do
+ before do
+ args[:builds] = [
+ { id: 1, name: "rspec", status: "failed", stage: "test" },
+ { id: 2, name: "karma", status: "success", stage: "test" }
+ ]
end
- it "returns two attachment fields" do
- expect(subject.attachments.first[:fields].count).to eq(2)
+ it "returns four attachment fields" do
+ expect(subject.attachments.first[:fields].count).to eq(4)
end
- it "returns the commit message as the attachment's second field property" do
- expect(subject.attachments.first[:fields][0]).to eq({
- title: "Branch",
- value: "<http://example.gitlab.com/commits/develop|develop>",
+ it "returns the stage name and link to the 'Failed jobs' tab on the pipeline's page as the attachment's third field property" do
+ expect(subject.attachments.first[:fields][2]).to eq({
+ title: "Failed stage",
+ value: "<http://example.gitlab.com/pipelines/123/failures|test>",
short: true
})
end
- it "returns the ref name and link as the attachment's second field property" do
- expect(subject.attachments.first[:fields][1]).to eq({
- title: "Commit",
- value: "<http://example.com/commit|A test commit message>",
+ it "returns the job name and link as the attachment's fourth field property" do
+ expect(subject.attachments.first[:fields][3]).to eq({
+ title: "Failed job",
+ value: "<http://example.gitlab.com/-/jobs/1|rspec>",
short: true
})
end
+ end
- context "when a job in the pipeline fails" do
- before do
- args[:builds] = [
- { id: 1, name: "rspec", status: "failed", stage: "test" },
- { id: 2, name: "karma", status: "success", stage: "test" }
- ]
- end
-
- it "returns four attachment fields" do
- expect(subject.attachments.first[:fields].count).to eq(4)
- end
-
- it "returns the stage name and link to the 'Failed jobs' tab on the pipeline's page as the attachment's third field property" do
- expect(subject.attachments.first[:fields][2]).to eq({
- title: "Failed stage",
- value: "<http://example.gitlab.com/pipelines/123/failures|test>",
- short: true
- })
- end
-
- it "returns the job name and link as the attachment's fourth field property" do
- expect(subject.attachments.first[:fields][3]).to eq({
- title: "Failed job",
- value: "<http://example.gitlab.com/-/jobs/1|rspec>",
- short: true
- })
+ context "when lots of jobs across multiple stages fail" do
+ before do
+ args[:builds] = (1..25).map do |i|
+ { id: i, name: "job-#{i}", status: "failed", stage: "stage-" + ((i % 3) + 1).to_s }
end
end
- context "when lots of jobs across multiple stages fail" do
- before do
- args[:builds] = (1..25).map do |i|
- { id: i, name: "job-#{i}", status: "failed", stage: "stage-" + ((i % 3) + 1).to_s }
- end
- end
+ it "returns the stage names and links to the 'Failed jobs' tab on the pipeline's page as the attachment's third field property" do
+ expect(subject.attachments.first[:fields][2]).to eq({
+ title: "Failed stages",
+ value: "<http://example.gitlab.com/pipelines/123/failures|stage-2>, <http://example.gitlab.com/pipelines/123/failures|stage-1>, <http://example.gitlab.com/pipelines/123/failures|stage-3>",
+ short: true
+ })
+ end
- it "returns the stage names and links to the 'Failed jobs' tab on the pipeline's page as the attachment's third field property" do
- expect(subject.attachments.first[:fields][2]).to eq({
- title: "Failed stages",
- value: "<http://example.gitlab.com/pipelines/123/failures|stage-2>, <http://example.gitlab.com/pipelines/123/failures|stage-1>, <http://example.gitlab.com/pipelines/123/failures|stage-3>",
- short: true
- })
+ it "returns the job names and links as the attachment's fourth field property" do
+ expected_jobs = 25.downto(16).map do |i|
+ "<http://example.gitlab.com/-/jobs/#{i}|job-#{i}>"
end
- it "returns the job names and links as the attachment's fourth field property" do
- expected_jobs = 25.downto(16).map do |i|
- "<http://example.gitlab.com/-/jobs/#{i}|job-#{i}>"
- end
+ expected_jobs << "and <http://example.gitlab.com/pipelines/123/failures|15 more>"
- expected_jobs << "and <http://example.gitlab.com/pipelines/123/failures|15 more>"
-
- expect(subject.attachments.first[:fields][3]).to eq({
- title: "Failed jobs",
- value: expected_jobs.join(", "),
- short: true
- })
- end
+ expect(subject.attachments.first[:fields][3]).to eq({
+ title: "Failed jobs",
+ value: expected_jobs.join(", "),
+ short: true
+ })
end
+ end
- context "when jobs succeed on retries" do
- before do
- args[:builds] = [
- { id: 1, name: "job-1", status: "failed", stage: "stage-1" },
- { id: 2, name: "job-2", status: "failed", stage: "stage-2" },
- { id: 3, name: "job-3", status: "failed", stage: "stage-3" },
- { id: 7, name: "job-1", status: "failed", stage: "stage-1" },
- { id: 8, name: "job-1", status: "success", stage: "stage-1" }
- ]
- end
-
- it "do not return a job which succeeded on retry" do
- expected_jobs = [
- "<http://example.gitlab.com/-/jobs/3|job-3>",
- "<http://example.gitlab.com/-/jobs/2|job-2>"
- ]
-
- expect(subject.attachments.first[:fields][3]).to eq(
- title: "Failed jobs",
- value: expected_jobs.join(", "),
- short: true
- )
- end
+ context "when jobs succeed on retries" do
+ before do
+ args[:builds] = [
+ { id: 1, name: "job-1", status: "failed", stage: "stage-1" },
+ { id: 2, name: "job-2", status: "failed", stage: "stage-2" },
+ { id: 3, name: "job-3", status: "failed", stage: "stage-3" },
+ { id: 7, name: "job-1", status: "failed", stage: "stage-1" },
+ { id: 8, name: "job-1", status: "success", stage: "stage-1" }
+ ]
+ end
+
+ it "do not return a job which succeeded on retry" do
+ expected_jobs = [
+ "<http://example.gitlab.com/-/jobs/3|job-3>",
+ "<http://example.gitlab.com/-/jobs/2|job-2>"
+ ]
+
+ expect(subject.attachments.first[:fields][3]).to eq(
+ title: "Failed jobs",
+ value: expected_jobs.join(", "),
+ short: true
+ )
end
+ end
- context "when jobs failed even on retries" do
- before do
- args[:builds] = [
- { id: 1, name: "job-1", status: "failed", stage: "stage-1" },
- { id: 2, name: "job-2", status: "failed", stage: "stage-2" },
- { id: 3, name: "job-3", status: "failed", stage: "stage-3" },
- { id: 7, name: "job-1", status: "failed", stage: "stage-1" },
- { id: 8, name: "job-1", status: "failed", stage: "stage-1" }
- ]
- end
-
- it "returns only first instance of the failed job" do
- expected_jobs = [
- "<http://example.gitlab.com/-/jobs/3|job-3>",
- "<http://example.gitlab.com/-/jobs/2|job-2>",
- "<http://example.gitlab.com/-/jobs/1|job-1>"
- ]
-
- expect(subject.attachments.first[:fields][3]).to eq(
- title: "Failed jobs",
- value: expected_jobs.join(", "),
- short: true
- )
- end
+ context "when jobs failed even on retries" do
+ before do
+ args[:builds] = [
+ { id: 1, name: "job-1", status: "failed", stage: "stage-1" },
+ { id: 2, name: "job-2", status: "failed", stage: "stage-2" },
+ { id: 3, name: "job-3", status: "failed", stage: "stage-3" },
+ { id: 7, name: "job-1", status: "failed", stage: "stage-1" },
+ { id: 8, name: "job-1", status: "failed", stage: "stage-1" }
+ ]
+ end
+
+ it "returns only first instance of the failed job" do
+ expected_jobs = [
+ "<http://example.gitlab.com/-/jobs/3|job-3>",
+ "<http://example.gitlab.com/-/jobs/2|job-2>",
+ "<http://example.gitlab.com/-/jobs/1|job-1>"
+ ]
+
+ expect(subject.attachments.first[:fields][3]).to eq(
+ title: "Failed jobs",
+ value: expected_jobs.join(", "),
+ short: true
+ )
end
+ end
- context "when the CI config file contains a YAML error" do
- let(:has_yaml_errors) { true }
-
- it "returns three attachment fields" do
- expect(subject.attachments.first[:fields].count).to eq(3)
- end
+ context "when the CI config file contains a YAML error" do
+ let(:has_yaml_errors) { true }
- it "returns the YAML error deatils as the attachment's third field property" do
- expect(subject.attachments.first[:fields][2]).to eq({
- title: "Invalid CI config YAML file",
- value: "yaml error description here",
- short: false
- })
- end
+ it "returns three attachment fields" do
+ expect(subject.attachments.first[:fields].count).to eq(3)
end
- it "returns the project's name as the attachment's footer property" do
- expect(subject.attachments.first[:footer]).to eq("project_name")
+ it "returns the YAML error deatils as the attachment's third field property" do
+ expect(subject.attachments.first[:fields][2]).to eq({
+ title: "Invalid CI config YAML file",
+ value: "yaml error description here",
+ short: false
+ })
end
+ end
- it "returns the project's avatar URL as the attachment's footer_icon property" do
- expect(subject.attachments.first[:footer_icon]).to eq("http://example.com/project_avatar")
- end
+ it "returns the project's name as the attachment's footer property" do
+ expect(subject.attachments.first[:footer]).to eq("project_name")
+ end
- it "returns the pipeline's timestamp as the attachment's ts property" do
- expected_ts = Time.parse(args[:object_attributes][:finished_at]).to_i
- expect(subject.attachments.first[:ts]).to eq(expected_ts)
- end
+ it "returns the project's avatar URL as the attachment's footer_icon property" do
+ expect(subject.attachments.first[:footer_icon]).to eq("http://example.com/project_avatar")
+ end
- context 'when rendering markdown' do
- before do
- args[:markdown] = true
- end
+ it "returns the pipeline's timestamp as the attachment's ts property" do
+ expected_ts = Time.parse(args[:object_attributes][:finished_at]).to_i
+ expect(subject.attachments.first[:ts]).to eq(expected_ts)
+ end
- it 'returns the pipeline summary as the attachments in markdown format' do
- expect(subject.attachments).to eq(
- "[project_name](http://example.gitlab.com):" \
- " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
- " of branch [develop](http://example.gitlab.com/commits/develop)" \
- " by The Hacker (hacker) has passed in 02:00:10"
- )
- end
+ context 'when rendering markdown' do
+ before do
+ args[:markdown] = true
+ end
+
+ it 'returns the pipeline summary as the attachments in markdown format' do
+ expect(subject.attachments).to eq(
+ "[project_name](http://example.gitlab.com):" \
+ " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \
+ " of branch [develop](http://example.gitlab.com/commits/develop)" \
+ " by The Hacker (hacker) has passed in 02:00:10"
+ )
end
end
end
diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb
index badc964db16..88a93eef214 100644
--- a/spec/models/project_services/irker_service_spec.rb
+++ b/spec/models/project_services/irker_service_spec.rb
@@ -65,7 +65,7 @@ describe IrkerService do
conn = @irker_server.accept
conn.each_line do |line|
- msg = JSON.parse(line.chomp("\n"))
+ msg = Gitlab::Json.parse(line.chomp("\n"))
expect(msg.keys).to match_array(%w(to privmsg))
expect(msg['to']).to match_array(["irc://chat.freenode.net/#commits",
"irc://test.net/#test"])
diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb
index 32e6b5afce5..a0d36f0a238 100644
--- a/spec/models/project_services/jira_service_spec.rb
+++ b/spec/models/project_services/jira_service_spec.rb
@@ -69,11 +69,23 @@ describe JiraService do
end
describe '.reference_pattern' do
- it_behaves_like 'allows project key on reference pattern'
+ using RSpec::Parameterized::TableSyntax
- it 'does not allow # on the code' do
- expect(described_class.reference_pattern.match('#123')).to be_nil
- expect(described_class.reference_pattern.match('1#23#12')).to be_nil
+ where(:key, :result) do
+ '#123' | ''
+ '1#23#12' | ''
+ 'JIRA-1234A' | 'JIRA-1234'
+ 'JIRA-1234-some_tag' | 'JIRA-1234'
+ 'JIRA-1234_some_tag' | 'JIRA-1234'
+ 'EXT_EXT-1234' | 'EXT_EXT-1234'
+ 'EXT3_EXT-1234' | 'EXT3_EXT-1234'
+ '3EXT_EXT-1234' | ''
+ end
+
+ with_them do
+ specify do
+ expect(described_class.reference_pattern.match(key).to_s).to eq(result)
+ end
end
end
@@ -570,6 +582,79 @@ describe JiraService do
end
end
+ describe '#create_cross_reference_note' do
+ let_it_be(:user) { build_stubbed(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let(:jira_service) do
+ described_class.new(
+ project: project,
+ url: url,
+ username: username,
+ password: password
+ )
+ end
+ let(:jira_issue) { ExternalIssue.new('JIRA-123', project) }
+
+ subject { jira_service.create_cross_reference_note(jira_issue, resource, user) }
+
+ shared_examples 'creates a comment on Jira' do
+ let(:issue_url) { "#{url}/rest/api/2/issue/JIRA-123" }
+ let(:comment_url) { "#{issue_url}/comment" }
+ let(:remote_link_url) { "#{issue_url}/remotelink" }
+
+ before do
+ allow(JIRA::Resource::Remotelink).to receive(:all).and_return([])
+ stub_request(:get, issue_url).with(basic_auth: [username, password])
+ stub_request(:post, comment_url).with(basic_auth: [username, password])
+ stub_request(:post, remote_link_url).with(basic_auth: [username, password])
+ end
+
+ it 'creates a comment on Jira' do
+ subject
+
+ expect(WebMock).to have_requested(:post, comment_url).with(
+ body: /mentioned this issue in/
+ ).once
+ end
+ end
+
+ context 'when resource is a commit' do
+ let(:resource) { project.commit('master') }
+
+ context 'when disabled' do
+ before do
+ allow_next_instance_of(JiraService) do |instance|
+ allow(instance).to receive(:commit_events) { false }
+ end
+ end
+
+ it { is_expected.to eq('Events for commits are disabled.') }
+ end
+
+ context 'when enabled' do
+ it_behaves_like 'creates a comment on Jira'
+ end
+ end
+
+ context 'when resource is a merge request' do
+ let(:resource) { build_stubbed(:merge_request, source_project: project) }
+
+ context 'when disabled' do
+ before do
+ allow_next_instance_of(JiraService) do |instance|
+ allow(instance).to receive(:merge_requests_events) { false }
+ end
+ end
+
+ it { is_expected.to eq('Events for merge requests are disabled.') }
+ end
+
+ context 'when enabled' do
+ it_behaves_like 'creates a comment on Jira'
+ end
+ end
+ end
+
describe '#test' do
let(:jira_service) do
described_class.new(
diff --git a/spec/models/project_services/mattermost_slash_commands_service_spec.rb b/spec/models/project_services/mattermost_slash_commands_service_spec.rb
index 87e482059f2..836181929e3 100644
--- a/spec/models/project_services/mattermost_slash_commands_service_spec.rb
+++ b/spec/models/project_services/mattermost_slash_commands_service_spec.rb
@@ -121,5 +121,12 @@ describe MattermostSlashCommandsService do
end
end
end
+
+ describe '#chat_responder' do
+ it 'returns the responder to use for Mattermost' do
+ expect(described_class.new.chat_responder)
+ .to eq(Gitlab::Chat::Responder::Mattermost)
+ end
+ end
end
end
diff --git a/spec/models/project_services/microsoft_teams_service_spec.rb b/spec/models/project_services/microsoft_teams_service_spec.rb
index d93b8a2cb40..425599c73d4 100644
--- a/spec/models/project_services/microsoft_teams_service_spec.rb
+++ b/spec/models/project_services/microsoft_teams_service_spec.rb
@@ -121,7 +121,7 @@ describe MicrosoftTeamsService do
message: "user created page: Awesome wiki_page"
}
end
- let(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: opts) }
+ let(:wiki_page) { create(:wiki_page, wiki: project.wiki, **opts) }
let(:wiki_page_sample_data) { Gitlab::DataBuilder::WikiPage.build(wiki_page, user, 'create') }
it "calls Microsoft Teams API" do
diff --git a/spec/models/project_services/webex_teams_service_spec.rb b/spec/models/project_services/webex_teams_service_spec.rb
new file mode 100644
index 00000000000..38977ef3b7d
--- /dev/null
+++ b/spec/models/project_services/webex_teams_service_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+describe WebexTeamsService do
+ it_behaves_like "chat service", "Webex Teams" do
+ let(:client_arguments) { webhook_url }
+ let(:content_key) { :markdown }
+ end
+end
diff --git a/spec/models/project_snippet_spec.rb b/spec/models/project_snippet_spec.rb
index 719a74f995d..c17a24dc7cf 100644
--- a/spec/models/project_snippet_spec.rb
+++ b/spec/models/project_snippet_spec.rb
@@ -38,5 +38,6 @@ describe ProjectSnippet do
let(:stubbed_container) { build_stubbed(:project_snippet) }
let(:expected_full_path) { "#{container.project.full_path}/@snippets/#{container.id}" }
let(:expected_web_url_path) { "#{container.project.full_path}/snippets/#{container.id}" }
+ let(:expected_repo_url_path) { expected_web_url_path }
end
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 4e75ef4fc87..5f8b51c250d 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -6,6 +6,7 @@ describe Project do
include ProjectForksHelper
include GitHelpers
include ExternalAuthorizationServiceHelpers
+ using RSpec::Parameterized::TableSyntax
it_behaves_like 'having unique enum values'
@@ -20,6 +21,7 @@ describe Project do
it { is_expected.to have_many(:merge_requests) }
it { is_expected.to have_many(:issues) }
it { is_expected.to have_many(:milestones) }
+ it { is_expected.to have_many(:iterations) }
it { is_expected.to have_many(:project_members).dependent(:delete_all) }
it { is_expected.to have_many(:users).through(:project_members) }
it { is_expected.to have_many(:requesters).dependent(:delete_all) }
@@ -34,6 +36,7 @@ describe Project do
it { is_expected.to have_one(:mattermost_service) }
it { is_expected.to have_one(:hangouts_chat_service) }
it { is_expected.to have_one(:unify_circuit_service) }
+ it { is_expected.to have_one(:webex_teams_service) }
it { is_expected.to have_one(:packagist_service) }
it { is_expected.to have_one(:pushover_service) }
it { is_expected.to have_one(:asana_service) }
@@ -110,7 +113,10 @@ describe Project do
it { is_expected.to have_many(:source_pipelines) }
it { is_expected.to have_many(:prometheus_alert_events) }
it { is_expected.to have_many(:self_managed_prometheus_alert_events) }
+ it { is_expected.to have_many(:alert_management_alerts) }
it { is_expected.to have_many(:jira_imports) }
+ it { is_expected.to have_many(:metrics_users_starred_dashboards).inverse_of(:project) }
+ it { is_expected.to have_many(:repository_storage_moves) }
it_behaves_like 'model with repository' do
let_it_be(:container) { create(:project, :repository, path: 'somewhere') }
@@ -118,6 +124,11 @@ describe Project do
let(:expected_full_path) { "#{container.namespace.full_path}/somewhere" }
end
+ it_behaves_like 'model with wiki' do
+ let(:container) { create(:project, :wiki_repo) }
+ let(:container_without_wiki) { create(:project) }
+ end
+
it 'has an inverse relationship with merge requests' do
expect(described_class.reflect_on_association(:merge_requests).has_inverse?).to eq(:target_project)
end
@@ -263,27 +274,6 @@ describe Project do
create(:project)
end
- describe 'wiki path conflict' do
- context "when the new path has been used by the wiki of other Project" do
- it 'has an error on the name attribute' do
- new_project = build_stubbed(:project, namespace_id: project.namespace_id, path: "#{project.path}.wiki")
-
- expect(new_project).not_to be_valid
- expect(new_project.errors[:name].first).to eq(_('has already been taken'))
- end
- end
-
- context "when the new wiki path has been used by the path of other Project" do
- it 'has an error on the name attribute' do
- project_with_wiki_suffix = create(:project, path: 'foo.wiki')
- new_project = build_stubbed(:project, namespace_id: project_with_wiki_suffix.namespace_id, path: 'foo')
-
- expect(new_project).not_to be_valid
- expect(new_project.errors[:name].first).to eq(_('has already been taken'))
- end
- end
- end
-
context 'repository storages inclusion' do
let(:project2) { build(:project, repository_storage: 'missing') }
@@ -1791,6 +1781,7 @@ describe Project do
let(:project) { create(:project, :repository) }
let(:repo) { double(:repo, exists?: true) }
let(:wiki) { double(:wiki, exists?: true) }
+ let(:design) { double(:design, exists?: true) }
it 'expires the caches of the repository and wiki' do
# In EE, there are design repositories as well
@@ -1804,8 +1795,13 @@ describe Project do
.with('foo.wiki', project, shard: project.repository_storage, repo_type: Gitlab::GlRepository::WIKI)
.and_return(wiki)
+ allow(Repository).to receive(:new)
+ .with('foo.design', project, shard: project.repository_storage, repo_type: Gitlab::GlRepository::DESIGN)
+ .and_return(design)
+
expect(repo).to receive(:before_delete)
expect(wiki).to receive(:before_delete)
+ expect(design).to receive(:before_delete)
project.expire_caches_before_rename('foo')
end
@@ -2849,12 +2845,16 @@ describe Project do
end
it 'schedules the transfer of the repository to the new storage and locks the project' do
- expect(ProjectUpdateRepositoryStorageWorker).to receive(:perform_async).with(project.id, 'test_second_storage')
+ expect(ProjectUpdateRepositoryStorageWorker).to receive(:perform_async).with(project.id, 'test_second_storage', anything)
project.change_repository_storage('test_second_storage')
project.save!
expect(project).to be_repository_read_only
+ expect(project.repository_storage_moves.last).to have_attributes(
+ source_storage_name: "default",
+ destination_storage_name: "test_second_storage"
+ )
end
it "doesn't schedule the transfer if the repository is already read-only" do
@@ -3139,6 +3139,45 @@ describe Project do
end
end
+ describe '#ci_instance_variables_for' do
+ let(:project) { create(:project) }
+
+ let!(:instance_variable) do
+ create(:ci_instance_variable, value: 'secret')
+ end
+
+ let!(:protected_instance_variable) do
+ create(:ci_instance_variable, :protected, value: 'protected')
+ end
+
+ subject { project.ci_instance_variables_for(ref: 'ref') }
+
+ before do
+ stub_application_setting(
+ default_branch_protection: Gitlab::Access::PROTECTION_NONE)
+ end
+
+ context 'when the ref is not protected' do
+ before do
+ allow(project).to receive(:protected_for?).with('ref').and_return(false)
+ end
+
+ it 'contains only the CI variables' do
+ is_expected.to contain_exactly(instance_variable)
+ end
+ end
+
+ context 'when the ref is protected' do
+ before do
+ allow(project).to receive(:protected_for?).with('ref').and_return(true)
+ end
+
+ it 'contains all the variables' do
+ is_expected.to contain_exactly(instance_variable, protected_instance_variable)
+ end
+ end
+ end
+
describe '#any_lfs_file_locks?', :request_store do
let_it_be(:project) { create(:project) }
@@ -3637,6 +3676,24 @@ describe Project do
expect(projects).to contain_exactly(public_project)
end
end
+
+ context 'with deploy token users' do
+ let_it_be(:private_project) { create(:project, :private) }
+
+ subject { described_class.all.public_or_visible_to_user(user) }
+
+ context 'deploy token user without project' do
+ let_it_be(:user) { create(:deploy_token) }
+
+ it { is_expected.to eq [] }
+ end
+
+ context 'deploy token user with project' do
+ let_it_be(:user) { create(:deploy_token, projects: [private_project]) }
+
+ it { is_expected.to include(private_project) }
+ end
+ end
end
describe '.ids_with_issuables_available_for' do
@@ -3760,7 +3817,7 @@ describe Project do
end
end
- describe '.filter_by_feature_visibility' do
+ describe '.filter_by_feature_visibility', :enable_admin_mode do
include_context 'ProjectPolicyTable context'
include ProjectHelpers
using RSpec::Parameterized::TableSyntax
@@ -3955,16 +4012,6 @@ describe Project do
expect { project.remove_pages }.to change { pages_metadatum.reload.deployed }.from(true).to(false)
end
- it 'is a no-op when there is no namespace' do
- project.namespace.delete
- project.reload
-
- expect_any_instance_of(Projects::UpdatePagesConfigurationService).not_to receive(:execute)
- expect_any_instance_of(Gitlab::PagesTransfer).not_to receive(:rename_project)
-
- expect { project.remove_pages }.not_to change { pages_metadatum.reload.deployed }
- end
-
it 'is run when the project is destroyed' do
expect(project).to receive(:remove_pages).and_call_original
@@ -4716,20 +4763,6 @@ describe Project do
end
end
- describe '#wiki_repository_exists?' do
- it 'returns true when the wiki repository exists' do
- project = create(:project, :wiki_repo)
-
- expect(project.wiki_repository_exists?).to eq(true)
- end
-
- it 'returns false when the wiki repository does not exist' do
- project = create(:project)
-
- expect(project.wiki_repository_exists?).to eq(false)
- end
- end
-
describe '#write_repository_config' do
let_it_be(:project) { create(:project, :repository) }
@@ -5972,6 +6005,158 @@ describe Project do
end
end
+ describe '#validate_jira_import_settings!' do
+ include JiraServiceHelper
+
+ let_it_be(:project, reload: true) { create(:project) }
+
+ shared_examples 'raise Jira import error' do |message|
+ it 'returns error' do
+ expect { subject }.to raise_error(Projects::ImportService::Error, message)
+ end
+ end
+
+ shared_examples 'jira configuration base checks' do
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(jira_issue_import: false)
+ end
+
+ it_behaves_like 'raise Jira import error', 'Jira import feature is disabled.'
+ end
+
+ context 'when feature flag is enabled' do
+ before do
+ stub_feature_flags(jira_issue_import: true)
+ end
+
+ context 'when Jira service was not setup' do
+ it_behaves_like 'raise Jira import error', 'Jira integration not configured.'
+ end
+
+ context 'when Jira service exists' do
+ let!(:jira_service) { create(:jira_service, project: project, active: true) }
+
+ context 'when Jira connection is not valid' do
+ before do
+ WebMock.stub_request(:get, 'https://jira.example.com/rest/api/2/serverInfo')
+ .to_raise(JIRA::HTTPError.new(double(message: 'Some failure.')))
+ end
+
+ it_behaves_like 'raise Jira import error', 'Unable to connect to the Jira instance. Please check your Jira integration configuration.'
+ end
+ end
+ end
+ end
+
+ before do
+ stub_jira_service_test
+ end
+
+ context 'without user param' do
+ subject { project.validate_jira_import_settings! }
+
+ it_behaves_like 'jira configuration base checks'
+
+ context 'when jira connection is valid' do
+ let!(:jira_service) { create(:jira_service, project: project, active: true) }
+
+ it 'does not return any error' do
+ expect { subject }.not_to raise_error
+ end
+ end
+ end
+
+ context 'with user param provided' do
+ let_it_be(:user) { create(:user) }
+
+ subject { project.validate_jira_import_settings!(user: user) }
+
+ context 'when user has permission to run import' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it_behaves_like 'jira configuration base checks'
+ end
+
+ context 'when feature flag is enabled' do
+ before do
+ stub_feature_flags(jira_issue_import: true)
+ end
+
+ context 'when user does not have permissions to run the import' do
+ before do
+ create(:jira_service, project: project, active: true)
+
+ project.add_developer(user)
+ end
+
+ it_behaves_like 'raise Jira import error', 'You do not have permissions to run the import.'
+ end
+
+ context 'when user has permission to run import' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ let!(:jira_service) { create(:jira_service, project: project, active: true) }
+
+ context 'when issues feature is disabled' do
+ let_it_be(:project, reload: true) { create(:project, :issues_disabled) }
+
+ it_behaves_like 'raise Jira import error', 'Cannot import because issues are not available in this project.'
+ end
+
+ context 'when everything is ok' do
+ it 'does not return any error' do
+ expect { subject }.not_to raise_error
+ end
+ end
+ end
+ end
+ end
+ end
+
+ describe '#design_management_enabled?' do
+ let(:project) { build(:project) }
+
+ where(:lfs_enabled, :hashed_storage_enabled, :expectation) do
+ false | false | false
+ true | false | false
+ false | true | false
+ true | true | true
+ end
+
+ with_them do
+ before do
+ expect(project).to receive(:lfs_enabled?).and_return(lfs_enabled)
+ allow(project).to receive(:hashed_storage?).with(:repository).and_return(hashed_storage_enabled)
+ end
+
+ it do
+ expect(project.design_management_enabled?).to be(expectation)
+ end
+ end
+ end
+
+ describe '#bots' do
+ subject { project.bots }
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:project_bot) { create(:user, :project_bot) }
+ let_it_be(:user) { create(:user) }
+
+ before_all do
+ [project_bot, user].each do |member|
+ project.add_maintainer(member)
+ end
+ end
+
+ it { is_expected.to contain_exactly(project_bot) }
+ it { is_expected.not_to include(user) }
+ end
+
def finish_job(export_job)
export_job.start
export_job.finish
diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb
index 1b121b7dee1..a4181e3be9a 100644
--- a/spec/models/project_wiki_spec.rb
+++ b/spec/models/project_wiki_spec.rb
@@ -1,448 +1,35 @@
# frozen_string_literal: true
-require "spec_helper"
+require 'spec_helper'
describe ProjectWiki do
- let(:user) { create(:user, :commit_email) }
- let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
- let(:repository) { project.repository }
- let(:gitlab_shell) { Gitlab::Shell.new }
- let(:project_wiki) { described_class.new(project, user) }
- let(:raw_repository) { Gitlab::Git::Repository.new(project.repository_storage, subject.disk_path + '.git', 'foo', 'group/project.wiki') }
- let(:commit) { project_wiki.repository.head_commit }
+ it_behaves_like 'wiki model' do
+ let(:wiki_container) { create(:project, :wiki_repo, namespace: user.namespace) }
+ let(:wiki_container_without_repo) { create(:project, namespace: user.namespace) }
- subject { project_wiki }
+ it { is_expected.to delegate_method(:storage).to(:container) }
+ it { is_expected.to delegate_method(:repository_storage).to(:container) }
+ it { is_expected.to delegate_method(:hashed_storage?).to(:container) }
- it { is_expected.to delegate_method(:repository_storage).to :project }
- it { is_expected.to delegate_method(:hashed_storage?).to :project }
-
- describe "#full_path" do
- it "returns the project path with namespace with the .wiki extension" do
- expect(subject.full_path).to eq(project.full_path + '.wiki')
- end
-
- it 'returns the same value as #full_path' do
- expect(subject.full_path).to eq(subject.full_path)
- end
- end
-
- describe '#web_url' do
- it 'returns the full web URL to the wiki' do
- expect(subject.web_url).to eq(Gitlab::UrlBuilder.build(subject))
- end
- end
-
- describe "#url_to_repo" do
- it "returns the correct ssh url to the repo" do
- expect(subject.url_to_repo).to eq(Gitlab::RepositoryUrlBuilder.build(subject.repository.full_path, protocol: :ssh))
- end
- end
-
- describe "#ssh_url_to_repo" do
- it "equals #url_to_repo" do
- expect(subject.ssh_url_to_repo).to eq(subject.url_to_repo)
- end
- end
-
- describe "#http_url_to_repo" do
- it "returns the correct http url to the repo" do
- expect(subject.http_url_to_repo).to eq(Gitlab::RepositoryUrlBuilder.build(subject.repository.full_path, protocol: :http))
- end
- end
-
- describe "#wiki_base_path" do
- it "returns the wiki base path" do
- wiki_base_path = "#{Gitlab.config.gitlab.relative_url_root}/#{project.full_path}/-/wikis"
-
- expect(subject.wiki_base_path).to eq(wiki_base_path)
- end
- end
-
- describe "#wiki" do
- it "contains a Gitlab::Git::Wiki instance" do
- expect(subject.wiki).to be_a Gitlab::Git::Wiki
- end
-
- it "creates a new wiki repo if one does not yet exist" do
- expect(project_wiki.create_page("index", "test content")).to be_truthy
- end
-
- it "creates a new wiki repo with a default commit message" do
- expect(project_wiki.create_page("index", "test content", :markdown, "")).to be_truthy
-
- page = project_wiki.find_page('index')
-
- expect(page.last_version.message).to eq("#{user.username} created page: index")
- end
-
- it "raises CouldNotCreateWikiError if it can't create the wiki repository" do
- # Create a fresh project which will not have a wiki
- project_wiki = described_class.new(create(:project), user)
- expect(project_wiki.repository).to receive(:create_if_not_exists) { false }
-
- expect { project_wiki.send(:wiki) }.to raise_exception(ProjectWiki::CouldNotCreateWikiError)
- end
- end
-
- describe "#empty?" do
- context "when the wiki repository is empty" do
- describe '#empty?' do
- subject { super().empty? }
-
- it { is_expected.to be_truthy }
- end
- end
-
- context "when the wiki has pages" do
- before do
- project_wiki.create_page("index", "This is an awesome new Gollum Wiki")
- project_wiki.create_page("another-page", "This is another page")
- end
-
- describe '#empty?' do
- subject { super().empty? }
-
- it { is_expected.to be_falsey }
-
- it 'only instantiates a Wiki page once' do
- expect(WikiPage).to receive(:new).once.and_call_original
-
- subject
- end
- end
- end
- end
-
- describe "#list_pages" do
- let(:wiki_pages) { subject.list_pages }
-
- before do
- create_page("index", "This is an index")
- create_page("index2", "This is an index2")
- create_page("an index3", "This is an index3")
- end
-
- after do
- wiki_pages.each do |wiki_page|
- destroy_page(wiki_page.page)
- end
- end
-
- it "returns an array of WikiPage instances" do
- expect(wiki_pages.first).to be_a WikiPage
- end
-
- it 'does not load WikiPage content by default' do
- wiki_pages.each do |page|
- expect(page.content).to be_empty
- end
- end
-
- it 'returns all pages by default' do
- expect(wiki_pages.count).to eq(3)
- end
-
- context "with limit option" do
- it 'returns limited set of pages' do
- expect(subject.list_pages(limit: 1).count).to eq(1)
- end
- end
-
- context "with sorting options" do
- it 'returns pages sorted by title by default' do
- pages = ['an index3', 'index', 'index2']
-
- expect(subject.list_pages.map(&:title)).to eq(pages)
- expect(subject.list_pages(direction: "desc").map(&:title)).to eq(pages.reverse)
- end
-
- it 'returns pages sorted by created_at' do
- pages = ['index', 'index2', 'an index3']
-
- expect(subject.list_pages(sort: 'created_at').map(&:title)).to eq(pages)
- expect(subject.list_pages(sort: 'created_at', direction: "desc").map(&:title)).to eq(pages.reverse)
- end
- end
-
- context "with load_content option" do
- let(:pages) { subject.list_pages(load_content: true) }
-
- it 'loads WikiPage content' do
- expect(pages.first.content).to eq("This is an index3")
- expect(pages.second.content).to eq("This is an index")
- expect(pages.third.content).to eq("This is an index2")
- end
- end
- end
-
- describe "#find_page" do
- before do
- create_page("index page", "This is an awesome Gollum Wiki")
- end
-
- after do
- subject.list_pages.each { |page| destroy_page(page.page) }
- end
-
- it "returns the latest version of the page if it exists" do
- page = subject.find_page("index page")
- expect(page.title).to eq("index page")
- end
-
- it "returns nil if the page does not exist" do
- expect(subject.find_page("non-existent")).to eq(nil)
- end
-
- it "can find a page by slug" do
- page = subject.find_page("index-page")
- expect(page.title).to eq("index page")
- end
-
- it "returns a WikiPage instance" do
- page = subject.find_page("index page")
- expect(page).to be_a WikiPage
- end
-
- context 'pages with multibyte-character title' do
- before do
- create_page("autre pagé", "C'est un génial Gollum Wiki")
- end
-
- it "can find a page by slug" do
- page = subject.find_page("autre pagé")
- expect(page.title).to eq("autre pagé")
- end
- end
-
- context 'pages with invalidly-encoded content' do
- before do
- create_page("encoding is fun", "f\xFCr".b)
- end
-
- it "can find the page" do
- page = subject.find_page("encoding is fun")
- expect(page.content).to eq("fr")
+ describe '#disk_path' do
+ it 'returns the repository storage path' do
+ expect(subject.disk_path).to eq("#{subject.container.disk_path}.wiki")
end
end
- end
-
- describe '#find_sidebar' do
- before do
- create_page(described_class::SIDEBAR, 'This is an awesome Sidebar')
- end
-
- after do
- subject.list_pages.each { |page| destroy_page(page.page) }
- end
-
- it 'finds the page defined as _sidebar' do
- page = subject.find_page('_sidebar')
-
- expect(page.content).to eq('This is an awesome Sidebar')
- end
- end
- describe '#find_file' do
- let(:image) { File.open(Rails.root.join('spec', 'fixtures', 'big-image.png')) }
+ describe '#update_container_activity' do
+ it 'updates project activity' do
+ wiki_container.update!(
+ last_activity_at: nil,
+ last_repository_updated_at: nil
+ )
- before do
- subject.wiki # Make sure the wiki repo exists
+ subject.create_page('Test Page', 'This is content')
+ wiki_container.reload
- repo_path = Gitlab::GitalyClient::StorageSettings.allow_disk_access do
- subject.repository.path_to_repo
+ expect(wiki_container.last_activity_at).to be_within(1.minute).of(Time.now)
+ expect(wiki_container.last_repository_updated_at).to be_within(1.minute).of(Time.now)
end
-
- BareRepoOperations.new(repo_path).commit_file(image, 'image.png')
- end
-
- it 'returns the latest version of the file if it exists' do
- file = subject.find_file('image.png')
- expect(file.mime_type).to eq('image/png')
- end
-
- it 'returns nil if the page does not exist' do
- expect(subject.find_file('non-existent')).to eq(nil)
- end
-
- it 'returns a Gitlab::Git::WikiFile instance' do
- file = subject.find_file('image.png')
- expect(file).to be_a Gitlab::Git::WikiFile
- end
-
- it 'returns the whole file' do
- file = subject.find_file('image.png')
- image.rewind
-
- expect(file.raw_data.b).to eq(image.read.b)
- end
- end
-
- describe "#create_page" do
- after do
- destroy_page(subject.list_pages.first.page)
- end
-
- it "creates a new wiki page" do
- expect(subject.create_page("test page", "this is content")).not_to eq(false)
- expect(subject.list_pages.count).to eq(1)
- end
-
- it "returns false when a duplicate page exists" do
- subject.create_page("test page", "content")
- expect(subject.create_page("test page", "content")).to eq(false)
end
-
- it "stores an error message when a duplicate page exists" do
- 2.times { subject.create_page("test page", "content") }
- expect(subject.error_message).to match(/Duplicate page:/)
- end
-
- it "sets the correct commit message" do
- subject.create_page("test page", "some content", :markdown, "commit message")
- expect(subject.list_pages.first.page.version.message).to eq("commit message")
- end
-
- it 'sets the correct commit email' do
- subject.create_page('test page', 'content')
-
- expect(user.commit_email).not_to eq(user.email)
- expect(commit.author_email).to eq(user.commit_email)
- expect(commit.committer_email).to eq(user.commit_email)
- end
-
- it 'updates project activity' do
- subject.create_page('Test Page', 'This is content')
-
- project.reload
-
- expect(project.last_activity_at).to be_within(1.minute).of(Time.now)
- expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now)
- end
- end
-
- describe "#update_page" do
- before do
- create_page("update-page", "some content")
- @gitlab_git_wiki_page = subject.wiki.page(title: "update-page")
- subject.update_page(
- @gitlab_git_wiki_page,
- content: "some other content",
- format: :markdown,
- message: "updated page"
- )
- @page = subject.list_pages(load_content: true).first.page
- end
-
- after do
- destroy_page(@page)
- end
-
- it "updates the content of the page" do
- expect(@page.raw_data).to eq("some other content")
- end
-
- it "sets the correct commit message" do
- expect(@page.version.message).to eq("updated page")
- end
-
- it 'sets the correct commit email' do
- expect(user.commit_email).not_to eq(user.email)
- expect(commit.author_email).to eq(user.commit_email)
- expect(commit.committer_email).to eq(user.commit_email)
- end
-
- it 'updates project activity' do
- subject.update_page(
- @gitlab_git_wiki_page,
- content: 'Yet more content',
- format: :markdown,
- message: 'Updated page again'
- )
-
- project.reload
-
- expect(project.last_activity_at).to be_within(1.minute).of(Time.now)
- expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now)
- end
- end
-
- describe "#delete_page" do
- before do
- create_page("index", "some content")
- @page = subject.wiki.page(title: "index")
- end
-
- it "deletes the page" do
- subject.delete_page(@page)
- expect(subject.list_pages.count).to eq(0)
- end
-
- it 'sets the correct commit email' do
- subject.delete_page(@page)
-
- expect(user.commit_email).not_to eq(user.email)
- expect(commit.author_email).to eq(user.commit_email)
- expect(commit.committer_email).to eq(user.commit_email)
- end
-
- it 'updates project activity' do
- subject.delete_page(@page)
-
- project.reload
-
- expect(project.last_activity_at).to be_within(1.minute).of(Time.now)
- expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now)
- end
- end
-
- describe '#ensure_repository' do
- let(:project) { create(:project) }
-
- it 'creates the repository if it not exist' do
- expect(raw_repository.exists?).to eq(false)
-
- subject.ensure_repository
-
- expect(raw_repository.exists?).to eq(true)
- end
-
- it 'does not create the repository if it exists' do
- subject.wiki
- expect(raw_repository.exists?).to eq(true)
-
- expect(subject).not_to receive(:create_repo!)
-
- subject.ensure_repository
- end
- end
-
- describe '#hook_attrs' do
- it 'returns a hash with values' do
- expect(subject.hook_attrs).to be_a Hash
- expect(subject.hook_attrs.keys).to contain_exactly(:web_url, :git_ssh_url, :git_http_url, :path_with_namespace, :default_branch)
- end
- end
-
- private
-
- def create_temp_repo(path)
- FileUtils.mkdir_p path
- system(*%W(#{Gitlab.config.git.bin_path} init --quiet --bare -- #{path}))
- end
-
- def remove_temp_repo(path)
- FileUtils.rm_rf path
- end
-
- def commit_details
- Gitlab::Git::Wiki::CommitDetails.new(user.id, user.username, user.name, user.commit_email, "test commit")
- end
-
- def create_page(name, content)
- subject.wiki.write_page(name, :markdown, content, commit_details)
- end
-
- def destroy_page(page)
- subject.delete_page(page, "test commit")
end
end
diff --git a/spec/models/release_spec.rb b/spec/models/release_spec.rb
index 8b1b738ab58..d72fd137f3f 100644
--- a/spec/models/release_spec.rb
+++ b/spec/models/release_spec.rb
@@ -111,26 +111,6 @@ RSpec.describe Release do
end
end
- describe '#notify_new_release' do
- context 'when a release is created' do
- it 'instantiates NewReleaseWorker to send notifications' do
- expect(NewReleaseWorker).to receive(:perform_async)
-
- create(:release)
- end
- end
-
- context 'when a release is updated' do
- let!(:release) { create(:release) }
-
- it 'does not send any new notification' do
- expect(NewReleaseWorker).not_to receive(:perform_async)
-
- release.update!(description: 'new description')
- end
- end
- end
-
describe '#name' do
context 'name is nil' do
before do
@@ -143,38 +123,6 @@ RSpec.describe Release do
end
end
- describe '#evidence_sha' do
- subject { release.evidence_sha }
-
- context 'when a release was created before evidence collection existed' do
- let!(:release) { create(:release) }
-
- it { is_expected.to be_nil }
- end
-
- context 'when a release was created with evidence collection' do
- let!(:release) { create(:release, :with_evidence) }
-
- it { is_expected.to eq(release.evidences.first.summary_sha) }
- end
- end
-
- describe '#evidence_summary' do
- subject { release.evidence_summary }
-
- context 'when a release was created before evidence collection existed' do
- let!(:release) { create(:release) }
-
- it { is_expected.to eq({}) }
- end
-
- context 'when a release was created with evidence collection' do
- let!(:release) { create(:release, :with_evidence) }
-
- it { is_expected.to eq(release.evidences.first.summary) }
- end
- end
-
describe '#milestone_titles' do
let(:release) { create(:release, :with_milestones) }
diff --git a/spec/models/remote_mirror_spec.rb b/spec/models/remote_mirror_spec.rb
index 15b162ae87a..a87cdcf9344 100644
--- a/spec/models/remote_mirror_spec.rb
+++ b/spec/models/remote_mirror_spec.rb
@@ -143,22 +143,54 @@ describe RemoteMirror, :mailer do
end
describe '#update_repository' do
- let(:git_remote_mirror) { spy }
+ it 'performs update including options' do
+ git_remote_mirror = stub_const('Gitlab::Git::RemoteMirror', spy)
+ mirror = build(:remote_mirror)
- before do
- stub_const('Gitlab::Git::RemoteMirror', git_remote_mirror)
+ expect(mirror).to receive(:options_for_update).and_return(keep_divergent_refs: true)
+ mirror.update_repository
+
+ expect(git_remote_mirror).to have_received(:new).with(
+ mirror.project.repository.raw,
+ mirror.remote_name,
+ keep_divergent_refs: true
+ )
+ expect(git_remote_mirror).to have_received(:update)
end
+ end
- it 'includes the `keep_divergent_refs` setting' do
+ describe '#options_for_update' do
+ it 'includes the `keep_divergent_refs` option' do
mirror = build_stubbed(:remote_mirror, keep_divergent_refs: true)
- mirror.update_repository({})
+ options = mirror.options_for_update
- expect(git_remote_mirror).to have_received(:new).with(
- anything,
- mirror.remote_name,
- hash_including(keep_divergent_refs: true)
- )
+ expect(options).to include(keep_divergent_refs: true)
+ end
+
+ it 'includes the `only_branches_matching` option' do
+ branch = create(:protected_branch)
+ mirror = build_stubbed(:remote_mirror, project: branch.project, only_protected_branches: true)
+
+ options = mirror.options_for_update
+
+ expect(options).to include(only_branches_matching: [branch.name])
+ end
+
+ it 'includes the `ssh_key` option' do
+ mirror = build(:remote_mirror, :ssh, ssh_private_key: 'private-key')
+
+ options = mirror.options_for_update
+
+ expect(options).to include(ssh_key: 'private-key')
+ end
+
+ it 'includes the `known_hosts` option' do
+ mirror = build(:remote_mirror, :ssh, ssh_known_hosts: 'known-hosts')
+
+ options = mirror.options_for_update
+
+ expect(options).to include(known_hosts: 'known-hosts')
end
end
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index ca04bd7a28a..be626dd6e32 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -2874,4 +2874,80 @@ describe Repository do
expect(repository.submodule_links).to be_a(Gitlab::SubmoduleLinks)
end
end
+
+ describe '#lfs_enabled?' do
+ let_it_be(:project) { create(:project, :repository, :design_repo, lfs_enabled: true) }
+
+ subject { repository.lfs_enabled? }
+
+ context 'for a project repository' do
+ let(:repository) { project.repository }
+
+ it 'returns true when LFS is enabled' do
+ stub_lfs_setting(enabled: true)
+
+ is_expected.to be_truthy
+ end
+
+ it 'returns false when LFS is disabled' do
+ stub_lfs_setting(enabled: false)
+
+ is_expected.to be_falsy
+ end
+ end
+
+ context 'for a project wiki repository' do
+ let(:repository) { project.wiki.repository }
+
+ it 'returns true when LFS is enabled' do
+ stub_lfs_setting(enabled: true)
+
+ is_expected.to be_truthy
+ end
+
+ it 'returns false when LFS is disabled' do
+ stub_lfs_setting(enabled: false)
+
+ is_expected.to be_falsy
+ end
+ end
+
+ context 'for a project snippet repository' do
+ let(:snippet) { create(:project_snippet, project: project) }
+ let(:repository) { snippet.repository }
+
+ it 'returns false when LFS is enabled' do
+ stub_lfs_setting(enabled: true)
+
+ is_expected.to be_falsy
+ end
+ end
+
+ context 'for a personal snippet repository' do
+ let(:snippet) { create(:personal_snippet) }
+ let(:repository) { snippet.repository }
+
+ it 'returns false when LFS is enabled' do
+ stub_lfs_setting(enabled: true)
+
+ is_expected.to be_falsy
+ end
+ end
+
+ context 'for a design repository' do
+ let(:repository) { project.design_repository }
+
+ it 'returns true when LFS is enabled' do
+ stub_lfs_setting(enabled: true)
+
+ is_expected.to be_truthy
+ end
+
+ it 'returns false when LFS is disabled' do
+ stub_lfs_setting(enabled: false)
+
+ is_expected.to be_falsy
+ end
+ end
+ end
end
diff --git a/spec/models/resource_label_event_spec.rb b/spec/models/resource_label_event_spec.rb
index ca887b485a2..a1a2150f461 100644
--- a/spec/models/resource_label_event_spec.rb
+++ b/spec/models/resource_label_event_spec.rb
@@ -15,9 +15,6 @@ RSpec.describe ResourceLabelEvent, type: :model do
it_behaves_like 'a resource event for merge requests'
describe 'associations' do
- it { is_expected.to belong_to(:user) }
- it { is_expected.to belong_to(:issue) }
- it { is_expected.to belong_to(:merge_request) }
it { is_expected.to belong_to(:label) }
end
diff --git a/spec/models/resource_milestone_event_spec.rb b/spec/models/resource_milestone_event_spec.rb
index bf8672f95c9..3f8d8b4c1df 100644
--- a/spec/models/resource_milestone_event_spec.rb
+++ b/spec/models/resource_milestone_event_spec.rb
@@ -78,4 +78,21 @@ describe ResourceMilestoneEvent, type: :model do
let(:query_method) { :remove? }
end
end
+
+ describe '#milestone_title' do
+ let(:milestone) { create(:milestone, title: 'v2.3') }
+ let(:event) { create(:resource_milestone_event, milestone: milestone) }
+
+ it 'returns the expected title' do
+ expect(event.milestone_title).to eq('v2.3')
+ end
+
+ context 'when milestone is nil' do
+ let(:event) { create(:resource_milestone_event, milestone: nil) }
+
+ it 'returns nil' do
+ expect(event.milestone_title).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/models/resource_state_event_spec.rb b/spec/models/resource_state_event_spec.rb
new file mode 100644
index 00000000000..986a13cbd0d
--- /dev/null
+++ b/spec/models/resource_state_event_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ResourceStateEvent, type: :model do
+ subject { build(:resource_state_event, issue: issue) }
+
+ let(:issue) { create(:issue) }
+ let(:merge_request) { create(:merge_request) }
+
+ it_behaves_like 'a resource event'
+ it_behaves_like 'a resource event for issues'
+ it_behaves_like 'a resource event for merge requests'
+end
diff --git a/spec/models/sent_notification_spec.rb b/spec/models/sent_notification_spec.rb
index fedaae372c4..087bc957373 100644
--- a/spec/models/sent_notification_spec.rb
+++ b/spec/models/sent_notification_spec.rb
@@ -326,4 +326,26 @@ describe SentNotification do
end
end
end
+
+ describe "#position=" do
+ subject { build(:sent_notification, noteable: create(:issue)) }
+
+ it "doesn't accept non-hash JSON passed as a string" do
+ subject.position = "true"
+
+ expect(subject.attributes_before_type_cast["position"]).to be(nil)
+ end
+
+ it "does accept a position hash as a string" do
+ subject.position = '{ "base_sha": "test" }'
+
+ expect(subject.position.base_sha).to eq("test")
+ end
+
+ it "does accept a hash" do
+ subject.position = { "base_sha" => "test" }
+
+ expect(subject.position.base_sha).to eq("test")
+ end
+ end
end
diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb
index cb8122b6573..106f8def42d 100644
--- a/spec/models/service_spec.rb
+++ b/spec/models/service_spec.rb
@@ -87,6 +87,20 @@ 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 '.confidential_note_hooks' do
it 'includes services where confidential_note_events is true' do
create(:service, active: true, confidential_note_events: true)
@@ -523,24 +537,6 @@ describe Service do
end
end
- describe "#deprecated?" do
- let(:project) { create(:project, :repository) }
-
- it 'returns false by default' do
- service = create(:service, project: project)
- expect(service.deprecated?).to be_falsy
- end
- end
-
- describe "#deprecation_message" do
- let(:project) { create(:project, :repository) }
-
- it 'is empty by default' do
- service = create(:service, project: project)
- expect(service.deprecation_message).to be_nil
- end
- end
-
describe '#api_field_names' do
let(:fake_service) do
Class.new(Service) do
diff --git a/spec/models/snippet_repository_spec.rb b/spec/models/snippet_repository_spec.rb
index dc9f9a95d24..255f07ebfa5 100644
--- a/spec/models/snippet_repository_spec.rb
+++ b/spec/models/snippet_repository_spec.rb
@@ -202,6 +202,38 @@ describe SnippetRepository do
it_behaves_like 'snippet repository with file names', 'snippetfile10.txt', 'snippetfile11.txt'
end
+
+ shared_examples 'snippet repository with git errors' do |path, error|
+ let(:new_file) { { file_path: path, content: 'bar' } }
+
+ it 'raises a path specific error' do
+ expect do
+ snippet_repository.multi_files_action(user, data, commit_opts)
+ end.to raise_error(error)
+ end
+ end
+
+ context 'with git errors' do
+ it_behaves_like 'snippet repository with git errors', 'invalid://path/here', described_class::InvalidPathError
+ it_behaves_like 'snippet repository with git errors', '../../path/traversal/here', described_class::InvalidPathError
+ it_behaves_like 'snippet repository with git errors', 'README', described_class::CommitError
+
+ context 'when user name is invalid' do
+ let(:user) { create(:user, name: '.') }
+
+ it_behaves_like 'snippet repository with git errors', 'non_existing_file', described_class::InvalidSignatureError
+ end
+
+ context 'when user email is empty' do
+ let(:user) { create(:user) }
+
+ before do
+ user.update_column(:email, '')
+ end
+
+ it_behaves_like 'snippet repository with git errors', 'non_existing_file', described_class::InvalidSignatureError
+ end
+ end
end
def blob_at(snippet, path)
diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb
index 2061084d5ea..4d6586c1df4 100644
--- a/spec/models/snippet_spec.rb
+++ b/spec/models/snippet_spec.rb
@@ -180,22 +180,6 @@ describe Snippet do
end
end
- describe '.search_code' do
- let(:snippet) { create(:snippet, content: 'class Foo; end') }
-
- it 'returns snippets with matching content' do
- expect(described_class.search_code(snippet.content)).to eq([snippet])
- end
-
- it 'returns snippets with partially matching content' do
- expect(described_class.search_code('class')).to eq([snippet])
- end
-
- it 'returns snippets with matching content regardless of the casing' do
- expect(described_class.search_code('FOO')).to eq([snippet])
- end
- end
-
describe 'when default snippet visibility set to internal' do
using RSpec::Parameterized::TableSyntax
@@ -545,11 +529,11 @@ describe Snippet do
let(:snippet) { build(:snippet) }
it 'excludes secret_token from generated json' do
- expect(JSON.parse(to_json).keys).not_to include("secret_token")
+ expect(Gitlab::Json.parse(to_json).keys).not_to include("secret_token")
end
it 'does not override existing exclude option value' do
- expect(JSON.parse(to_json(except: [:id])).keys).not_to include("secret_token", "id")
+ expect(Gitlab::Json.parse(to_json(except: [:id])).keys).not_to include("secret_token", "id")
end
def to_json(params = {})
@@ -735,31 +719,35 @@ describe Snippet do
end
end
- describe '#versioned_enabled_for?' do
- let_it_be(:user) { create(:user) }
+ describe '#url_to_repo' do
+ subject { snippet.url_to_repo }
+
+ context 'with personal snippet' do
+ let(:snippet) { create(:personal_snippet) }
- subject { snippet.versioned_enabled_for?(user) }
+ it { is_expected.to eq(Gitlab.config.gitlab_shell.ssh_path_prefix + "snippets/#{snippet.id}.git") }
+ end
- context 'with repository and version_snippets enabled' do
- let!(:snippet) { create(:personal_snippet, :repository, author: user) }
+ context 'with project snippet' do
+ let(:snippet) { create(:project_snippet) }
- it { is_expected.to be_truthy }
+ it { is_expected.to eq(Gitlab.config.gitlab_shell.ssh_path_prefix + "#{snippet.project.full_path}/snippets/#{snippet.id}.git") }
end
+ end
- context 'without repository' do
- let!(:snippet) { create(:personal_snippet, author: user) }
+ describe '.max_file_limit' do
+ subject { described_class.max_file_limit(nil) }
- it { is_expected.to be_falsy }
+ it "returns #{Snippet::MAX_FILE_COUNT}" do
+ expect(subject).to eq Snippet::MAX_FILE_COUNT
end
- context 'without version_snippets feature disabled' do
- let!(:snippet) { create(:personal_snippet, :repository, author: user) }
+ context 'when feature flag :snippet_multiple_files is disabled' do
+ it "returns #{described_class::MAX_SINGLE_FILE_COUNT}" do
+ stub_feature_flags(snippet_multiple_files: false)
- before do
- stub_feature_flags(version_snippets: false)
+ expect(subject).to eq described_class::MAX_SINGLE_FILE_COUNT
end
-
- it { is_expected.to be_falsy }
end
end
end
diff --git a/spec/models/spam_log_spec.rb b/spec/models/spam_log_spec.rb
index 8ebd97de9ff..8d0f247b5d6 100644
--- a/spec/models/spam_log_spec.rb
+++ b/spec/models/spam_log_spec.rb
@@ -20,15 +20,30 @@ describe SpamLog do
expect { spam_log.remove_user(deleted_by: admin) }.to change { spam_log.user.blocked? }.to(true)
end
- it 'removes the user', :sidekiq_might_not_need_inline do
- spam_log = build(:spam_log)
- user = spam_log.user
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'removes the user', :sidekiq_might_not_need_inline do
+ spam_log = build(:spam_log)
+ user = spam_log.user
+
+ perform_enqueued_jobs do
+ spam_log.remove_user(deleted_by: admin)
+ end
- perform_enqueued_jobs do
- spam_log.remove_user(deleted_by: admin)
+ expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound)
end
+ end
- expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound)
+ context 'when admin mode is disabled' do
+ it 'does not allow to remove the user', :sidekiq_might_not_need_inline do
+ spam_log = build(:spam_log)
+ user = spam_log.user
+
+ perform_enqueued_jobs do
+ spam_log.remove_user(deleted_by: admin)
+ end
+
+ expect(User.exists?(user.id)).to be(true)
+ end
end
end
diff --git a/spec/models/state_note_spec.rb b/spec/models/state_note_spec.rb
new file mode 100644
index 00000000000..d3409315e41
--- /dev/null
+++ b/spec/models/state_note_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe StateNote do
+ describe '.from_event' do
+ let_it_be(:author) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:noteable) { create(:issue, author: author, project: project) }
+
+ ResourceStateEvent.states.each do |state, _value|
+ context "with event state #{state}" do
+ let_it_be(:event) { create(:resource_state_event, issue: noteable, state: state, created_at: '2020-02-05') }
+
+ subject { described_class.from_event(event, resource: noteable, resource_parent: project) }
+
+ it_behaves_like 'a system note', exclude_project: true do
+ let(:action) { state.to_s }
+ end
+
+ it 'contains the expected values' do
+ expect(subject.author).to eq(author)
+ expect(subject.created_at).to eq(event.created_at)
+ expect(subject.note_html).to eq("<p dir=\"auto\">#{state}</p>")
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/timelog_spec.rb b/spec/models/timelog_spec.rb
index 33c1afad59f..ae1697fb7e6 100644
--- a/spec/models/timelog_spec.rb
+++ b/spec/models/timelog_spec.rb
@@ -56,12 +56,12 @@ RSpec.describe Timelog do
end
end
- describe 'between_dates' do
- it 'returns collection of timelogs within given dates' do
+ describe 'between_times' do
+ it 'returns collection of timelogs within given times' do
create(:timelog, spent_at: 65.days.ago)
timelog1 = create(:timelog, spent_at: 15.days.ago)
timelog2 = create(:timelog, spent_at: 5.days.ago)
- timelogs = described_class.between_dates(20.days.ago, 1.day.ago)
+ timelogs = described_class.between_times(20.days.ago, 1.day.ago)
expect(timelogs).to contain_exactly(timelog1, timelog2)
end
diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb
index 3f0c95b2513..e125f58399e 100644
--- a/spec/models/todo_spec.rb
+++ b/spec/models/todo_spec.rb
@@ -61,11 +61,13 @@ describe Todo do
describe '#done' do
it 'changes state to done' do
todo = create(:todo, state: :pending)
+
expect { todo.done }.to change(todo, :state).from('pending').to('done')
end
it 'does not raise error when is already done' do
todo = create(:todo, state: :done)
+
expect { todo.done }.not_to raise_error
end
end
@@ -73,15 +75,31 @@ describe Todo do
describe '#for_commit?' do
it 'returns true when target is a commit' do
subject.target_type = 'Commit'
+
expect(subject.for_commit?).to eq true
end
it 'returns false when target is an issuable' do
subject.target_type = 'Issue'
+
expect(subject.for_commit?).to eq false
end
end
+ describe '#for_design?' do
+ it 'returns true when target is a Design' do
+ subject.target_type = 'DesignManagement::Design'
+
+ expect(subject.for_design?).to eq(true)
+ end
+
+ it 'returns false when target is not a Design' do
+ subject.target_type = 'Issue'
+
+ expect(subject.for_design?).to eq(false)
+ end
+ end
+
describe '#target' do
context 'for commits' do
let(:project) { create(:project, :repository) }
@@ -108,6 +126,7 @@ describe Todo do
it 'returns the issuable for issuables' do
subject.target_id = issue.id
subject.target_type = issue.class.name
+
expect(subject.target).to eq issue
end
end
@@ -126,6 +145,7 @@ describe Todo do
it 'returns full reference for issuables' do
subject.target = issue
+
expect(subject.target_reference).to eq issue.to_reference(full: false)
end
end
@@ -389,5 +409,17 @@ describe Todo do
expect(described_class.update_state(:pending)).to be_empty
end
+
+ it 'updates updated_at' do
+ create(:todo, :pending)
+
+ Timecop.freeze(1.day.from_now) do
+ expected_update_date = Time.now.utc
+
+ ids = described_class.update_state(:done)
+
+ expect(Todo.where(id: ids).map(&:updated_at)).to all(be_like_time(expected_update_date))
+ end
+ end
end
end
diff --git a/spec/models/tree_spec.rb b/spec/models/tree_spec.rb
index c2d5dfdf9c4..7dde8459f9a 100644
--- a/spec/models/tree_spec.rb
+++ b/spec/models/tree_spec.rb
@@ -9,15 +9,18 @@ describe Tree do
subject { described_class.new(repository, '54fcc214') }
describe '#readme' do
- class FakeBlob
- attr_reader :name
-
- def initialize(name)
- @name = name
- end
-
- def readme?
- name =~ /^readme/i
+ before do
+ stub_const('FakeBlob', Class.new)
+ FakeBlob.class_eval do
+ attr_reader :name
+
+ def initialize(name)
+ @name = name
+ end
+
+ def readme?
+ name =~ /^readme/i
+ end
end
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 8597397c3c6..94a3f6bafea 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-describe User, :do_not_mock_admin_mode do
+describe User do
include ProjectForksHelper
include TermsHelper
include ExclusiveLeaseHelpers
@@ -17,6 +17,7 @@ describe User, :do_not_mock_admin_mode do
it { is_expected.to include_module(Sortable) }
it { is_expected.to include_module(TokenAuthenticatable) }
it { is_expected.to include_module(BlocksJsonSerialization) }
+ it { is_expected.to include_module(AsyncDeviseEmail) }
end
describe 'delegations' do
@@ -54,6 +55,7 @@ describe User, :do_not_mock_admin_mode do
it { is_expected.to have_many(:reported_abuse_reports).dependent(:destroy).class_name('AbuseReport') }
it { is_expected.to have_many(:custom_attributes).class_name('UserCustomAttribute') }
it { is_expected.to have_many(:releases).dependent(:nullify) }
+ it { is_expected.to have_many(:metrics_users_starred_dashboards).inverse_of(:user) }
describe "#bio" do
it 'syncs bio with `user_details.bio` on create' do
@@ -164,6 +166,18 @@ describe User, :do_not_mock_admin_mode do
end
end
+ describe 'Devise emails' do
+ let!(:user) { create(:user) }
+
+ describe 'behaviour' do
+ it 'sends emails asynchronously' do
+ expect do
+ user.update!(email: 'hello@hello.com')
+ end.to have_enqueued_job.on_queue('mailers').exactly(:twice)
+ end
+ end
+ end
+
describe 'validations' do
describe 'password' do
let!(:user) { create(:user) }
@@ -295,7 +309,7 @@ describe User, :do_not_mock_admin_mode do
subject { build(:user) }
end
- it_behaves_like 'an object with email-formated attributes', :public_email, :notification_email do
+ it_behaves_like 'an object with RFC3696 compliant email-formated attributes', :public_email, :notification_email do
subject { build(:user).tap { |user| user.emails << build(:email, email: email_value) } }
end
@@ -538,18 +552,6 @@ describe User, :do_not_mock_admin_mode do
expect(user).to be_valid
end
- context 'when feature flag is turned off' do
- before do
- stub_feature_flags(email_restrictions: false)
- end
-
- it 'does accept the email address' do
- user = build(:user, email: 'info+1@test.com')
-
- expect(user).to be_valid
- end
- end
-
context 'when created_by_id is set' do
it 'does accept the email address' do
user = build(:user, email: 'info+1@test.com', created_by_id: 1)
@@ -813,7 +815,7 @@ describe User, :do_not_mock_admin_mode do
describe '.active_without_ghosts' do
let_it_be(:user1) { create(:user, :external) }
let_it_be(:user2) { create(:user, state: 'blocked') }
- let_it_be(:user3) { create(:user, ghost: true) }
+ let_it_be(:user3) { create(:user, :ghost) }
let_it_be(:user4) { create(:user) }
it 'returns all active users but ghost users' do
@@ -824,7 +826,7 @@ describe User, :do_not_mock_admin_mode do
describe '.without_ghosts' do
let_it_be(:user1) { create(:user, :external) }
let_it_be(:user2) { create(:user, state: 'blocked') }
- let_it_be(:user3) { create(:user, ghost: true) }
+ let_it_be(:user3) { create(:user, :ghost) }
it 'returns users without ghosts users' do
expect(described_class.without_ghosts).to match_array([user1, user2])
@@ -927,7 +929,6 @@ describe User, :do_not_mock_admin_mode do
user.tap { |u| u.update!(email: new_email) }.reload
end.to change(user, :unconfirmed_email).to(new_email)
end
-
it 'does not change :notification_email' do
expect do
user.tap { |u| u.update!(email: new_email) }.reload
@@ -3275,7 +3276,6 @@ describe User, :do_not_mock_admin_mode do
expect(ghost.namespace).not_to be_nil
expect(ghost.namespace).to be_persisted
expect(ghost.user_type).to eq 'ghost'
- expect(ghost.ghost).to eq true
end
it "does not create a second ghost user if one is already present" do
@@ -4077,7 +4077,7 @@ describe User, :do_not_mock_admin_mode do
context 'in single-user environment' do
it 'requires user consent after one week' do
- create(:user, ghost: true)
+ create(:user, :ghost)
expect(user.requires_usage_stats_consent?).to be true
end
@@ -4355,31 +4355,15 @@ describe User, :do_not_mock_admin_mode do
end
end
- describe 'internal methods' do
- let_it_be(:user) { create(:user) }
- let_it_be(:ghost) { described_class.ghost }
- let_it_be(:alert_bot) { described_class.alert_bot }
- let_it_be(:project_bot) { create(:user, :project_bot) }
- let_it_be(:non_internal) { [user, project_bot] }
- let_it_be(:internal) { [ghost, alert_bot] }
+ describe '.active_without_ghosts' do
+ let_it_be(:user1) { create(:user, :external) }
+ let_it_be(:user2) { create(:user, state: 'blocked') }
+ let_it_be(:user3) { create(:user, :ghost) }
+ let_it_be(:user4) { create(:user, user_type: :support_bot) }
+ let_it_be(:user5) { create(:user, state: 'blocked', user_type: :support_bot) }
- it 'returns internal users' do
- expect(described_class.internal).to match_array(internal)
- expect(internal.all?(&:internal?)).to eq(true)
- end
-
- it 'returns non internal users' do
- expect(described_class.non_internal).to match_array(non_internal)
- expect(non_internal.all?(&:internal?)).to eq(false)
- end
-
- describe '#bot?' do
- it 'marks bot users' do
- expect(user.bot?).to eq(false)
- expect(ghost.bot?).to eq(false)
-
- expect(alert_bot.bot?).to eq(true)
- end
+ it 'returns all active users including active bots but ghost users' do
+ expect(described_class.active_without_ghosts).to match_array([user1, user4])
end
end
@@ -4417,19 +4401,6 @@ describe User, :do_not_mock_admin_mode do
end
end
- describe 'bots & humans' do
- it 'returns corresponding users' do
- human = create(:user)
- bot = create(:user, :bot)
- project_bot = create(:user, :project_bot)
-
- expect(described_class.humans).to match_array([human])
- expect(described_class.bots).to match_array([bot, project_bot])
- expect(described_class.bots_without_project_bot).to match_array([bot])
- expect(described_class.with_project_bots).to match_array([human, project_bot])
- end
- end
-
describe '#hook_attrs' do
it 'includes name, username, avatar_url, and email' do
user = create(:user)
@@ -4458,45 +4429,6 @@ describe User, :do_not_mock_admin_mode do
end
end
- describe '#gitlab_employee?' do
- using RSpec::Parameterized::TableSyntax
-
- subject { user.gitlab_employee? }
-
- where(:email, :is_com, :expected_result) do
- 'test@gitlab.com' | true | true
- 'test@example.com' | true | false
- 'test@gitlab.com' | false | false
- 'test@example.com' | false | false
- end
-
- with_them do
- let(:user) { build(:user, email: email) }
-
- before do
- allow(Gitlab).to receive(:com?).and_return(is_com)
- end
-
- it { is_expected.to be expected_result }
- end
-
- context 'when email is of Gitlab and is not confirmed' do
- let(:user) { build(:user, email: 'test@gitlab.com', confirmed_at: nil) }
-
- it { is_expected.to be false }
- end
-
- context 'when `:gitlab_employee_badge` feature flag is disabled' do
- let(:user) { build(:user, email: 'test@gitlab.com') }
-
- before do
- stub_feature_flags(gitlab_employee_badge: false)
- end
-
- it { is_expected.to be false }
- end
- end
-
describe '#current_highest_access_level' do
let_it_be(:user) { create(:user) }
@@ -4517,27 +4449,6 @@ describe User, :do_not_mock_admin_mode do
end
end
- describe '#organization' do
- using RSpec::Parameterized::TableSyntax
-
- let(:user) { build(:user, organization: 'ACME') }
-
- subject { user.organization }
-
- where(:gitlab_employee?, :expected_result) do
- true | 'GitLab'
- false | 'ACME'
- end
-
- with_them do
- before do
- allow(user).to receive(:gitlab_employee?).and_return(gitlab_employee?)
- end
-
- it { is_expected.to eql(expected_result) }
- end
- end
-
context 'when after_commit :update_highest_role' do
describe 'create user' do
subject { create(:user) }
@@ -4563,7 +4474,7 @@ describe User, :do_not_mock_admin_mode do
where(:attributes) do
[
{ state: 'blocked' },
- { ghost: true },
+ { user_type: :ghost },
{ user_type: :alert_bot }
]
end
@@ -4606,7 +4517,7 @@ describe User, :do_not_mock_admin_mode do
context 'when user is a ghost user' do
before do
- user.update(ghost: true)
+ user.update(user_type: :ghost)
end
it { is_expected.to be false }
@@ -4645,7 +4556,7 @@ describe User, :do_not_mock_admin_mode do
context 'when user is an internal user' do
before do
- user.update(ghost: true)
+ user.update(user_type: :ghost)
end
it { is_expected.to be User::LOGIN_FORBIDDEN }
@@ -4685,4 +4596,20 @@ describe User, :do_not_mock_admin_mode do
it_behaves_like 'does not require password to be present'
end
end
+
+ describe '#migration_bot' do
+ it 'creates the user if it does not exist' do
+ expect do
+ described_class.migration_bot
+ end.to change { User.where(user_type: :migration_bot).count }.by(1)
+ end
+
+ it 'does not create a new user if it already exists' do
+ described_class.migration_bot
+
+ expect do
+ described_class.migration_bot
+ end.not_to change { User.count }
+ end
+ end
end
diff --git a/spec/models/user_type_enums_spec.rb b/spec/models/user_type_enums_spec.rb
deleted file mode 100644
index 4f56e6ea96e..00000000000
--- a/spec/models/user_type_enums_spec.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe UserTypeEnums do
- it '.types' do
- expect(described_class.types.keys).to include('alert_bot', 'project_bot', 'human', 'ghost')
- end
-
- it '.bots' do
- expect(described_class.bots.keys).to include('alert_bot', 'project_bot')
- end
-end
diff --git a/spec/models/wiki_page/meta_spec.rb b/spec/models/wiki_page/meta_spec.rb
index f9bfc31ba64..0255dd802cf 100644
--- a/spec/models/wiki_page/meta_spec.rb
+++ b/spec/models/wiki_page/meta_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe WikiPage::Meta do
- let_it_be(:project) { create(:project) }
+ let_it_be(:project) { create(:project, :wiki_repo) }
let_it_be(:other_project) { create(:project) }
describe 'Associations' do
@@ -169,8 +169,11 @@ describe WikiPage::Meta do
described_class.find_or_create(last_known_slug, wiki_page)
end
- def create_previous_version(title = old_title, slug = last_known_slug)
- create(:wiki_page_meta, title: title, project: project, canonical_slug: slug)
+ def create_previous_version(title: old_title, slug: last_known_slug, date: wiki_page.version.commit.committed_date)
+ create(:wiki_page_meta,
+ title: title, project: project,
+ created_at: date, updated_at: date,
+ canonical_slug: slug)
end
def create_context
@@ -198,6 +201,8 @@ describe WikiPage::Meta do
title: wiki_page.title,
project: wiki_page.wiki.project
)
+ expect(meta.updated_at).to eq(wiki_page.version.commit.committed_date)
+ expect(meta.created_at).not_to be_after(meta.updated_at)
expect(meta.slugs.where(slug: last_known_slug)).to exist
expect(meta.slugs.canonical.where(slug: wiki_page.slug)).to exist
end
@@ -209,22 +214,32 @@ describe WikiPage::Meta do
end
end
- context 'the slug is too long' do
- let(:last_known_slug) { FFaker::Lorem.characters(2050) }
+ context 'there are problems' do
+ context 'the slug is too long' do
+ let(:last_known_slug) { FFaker::Lorem.characters(2050) }
- it 'raises an error' do
- expect { find_record }.to raise_error ActiveRecord::ValueTooLong
+ it 'raises an error' do
+ expect { find_record }.to raise_error ActiveRecord::ValueTooLong
+ end
end
- end
- context 'a conflicting record exists' do
- before do
- create(:wiki_page_meta, project: project, canonical_slug: last_known_slug)
- create(:wiki_page_meta, project: project, canonical_slug: current_slug)
+ context 'a conflicting record exists' do
+ before do
+ create(:wiki_page_meta, project: project, canonical_slug: last_known_slug)
+ create(:wiki_page_meta, project: project, canonical_slug: current_slug)
+ end
+
+ it 'raises an error' do
+ expect { find_record }.to raise_error(ActiveRecord::RecordInvalid)
+ end
end
- it 'raises an error' do
- expect { find_record }.to raise_error(ActiveRecord::RecordInvalid)
+ context 'the wiki page is not valid' do
+ let(:wiki_page) { build(:wiki_page, project: project, title: nil) }
+
+ it 'raises an error' do
+ expect { find_record }.to raise_error(described_class::WikiPageInvalid)
+ end
end
end
@@ -258,6 +273,17 @@ describe WikiPage::Meta do
end
end
+ context 'the commit happened a day ago' do
+ before do
+ allow(wiki_page.version.commit).to receive(:committed_date).and_return(1.day.ago)
+ end
+
+ include_examples 'metadata examples' do
+ # Identical to the base case.
+ let(:query_limit) { 5 }
+ end
+ end
+
context 'the last_known_slug is the same as the current slug, as on creation' do
let(:last_known_slug) { current_slug }
@@ -292,6 +318,33 @@ describe WikiPage::Meta do
end
end
+ context 'a record exists in the DB, but we need to update timestamps' do
+ let(:last_known_slug) { current_slug }
+ let(:old_title) { title }
+
+ before do
+ create_previous_version(date: 1.week.ago)
+ end
+
+ include_examples 'metadata examples' do
+ # We need the query, and the update
+ # SAVEPOINT active_record_2
+ #
+ # SELECT * FROM wiki_page_meta
+ # INNER JOIN wiki_page_slugs
+ # ON wiki_page_slugs.wiki_page_meta_id = wiki_page_meta.id
+ # WHERE wiki_page_meta.project_id = ?
+ # AND wiki_page_slugs.canonical = TRUE
+ # AND wiki_page_slugs.slug = ?
+ # LIMIT 2
+ #
+ # UPDATE wiki_page_meta SET updated_at = ?date WHERE id = ?id
+ #
+ # RELEASE SAVEPOINT active_record_2
+ let(:query_limit) { 4 }
+ end
+ end
+
context 'we need to update the slug, but not the title' do
let(:old_title) { title }
@@ -359,14 +412,14 @@ describe WikiPage::Meta do
end
context 'we want to change the slug back to a previous version' do
- let(:slug_1) { 'foo' }
- let(:slug_2) { 'bar' }
+ let(:slug_1) { generate(:sluggified_title) }
+ let(:slug_2) { generate(:sluggified_title) }
let(:wiki_page) { create(:wiki_page, title: slug_1, project: project) }
let(:last_known_slug) { slug_2 }
before do
- meta = create_previous_version(title, slug_1)
+ meta = create_previous_version(title: title, slug: slug_1)
meta.canonical_slug = slug_2
end
diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb
index 718b386b3fd..201dc85daf8 100644
--- a/spec/models/wiki_page_spec.rb
+++ b/spec/models/wiki_page_spec.rb
@@ -3,20 +3,11 @@
require "spec_helper"
describe WikiPage do
- let(:project) { create(:project, :wiki_repo) }
- let(:user) { project.owner }
- let(:wiki) { ProjectWiki.new(project, user) }
-
- let(:new_page) do
- described_class.new(wiki).tap do |page|
- page.attributes = { title: 'test page', content: 'test content' }
- end
- end
-
- let(:existing_page) do
- create_page('test page', 'test content')
- wiki.find_page('test page')
- end
+ let_it_be(:user) { create(:user) }
+ let(:container) { create(:project, :wiki_repo) }
+ let(:wiki) { Wiki.for_container(container, user) }
+ let(:new_page) { build(:wiki_page, wiki: wiki, title: 'test page', content: 'test content') }
+ let(:existing_page) { create(:wiki_page, wiki: wiki, title: 'test page', content: 'test content', message: 'test commit') }
subject { new_page }
@@ -24,11 +15,8 @@ describe WikiPage do
stub_feature_flags(Gitlab::WikiPages::FrontMatterParser::FEATURE_FLAG => false)
end
- def enable_front_matter_for_project
- stub_feature_flags(Gitlab::WikiPages::FrontMatterParser::FEATURE_FLAG => {
- thing: project,
- enabled: true
- })
+ def enable_front_matter_for(thing)
+ stub_feature_flags(Gitlab::WikiPages::FrontMatterParser::FEATURE_FLAG => thing)
end
describe '.group_by_directory' do
@@ -41,13 +29,13 @@ describe WikiPage do
context 'when there are pages' do
before do
- create_page('dir_1/dir_1_1/page_3', 'content')
- create_page('page_1', 'content')
- create_page('dir_1/page_2', 'content')
- create_page('dir_2', 'page with dir name')
- create_page('dir_2/page_5', 'content')
- create_page('page_6', 'content')
- create_page('dir_2/page_4', 'content')
+ wiki.create_page('dir_1/dir_1_1/page_3', 'content')
+ wiki.create_page('page_1', 'content')
+ wiki.create_page('dir_1/page_2', 'content')
+ wiki.create_page('dir_2', 'page with dir name')
+ wiki.create_page('dir_2/page_5', 'content')
+ wiki.create_page('page_6', 'content')
+ wiki.create_page('dir_2/page_4', 'content')
end
let(:page_1) { wiki.find_page('page_1') }
@@ -114,7 +102,8 @@ describe WikiPage do
describe '#front_matter' do
let_it_be(:project) { create(:project) }
- let(:wiki_page) { create(:wiki_page, project: project, content: content) }
+ let(:container) { project }
+ let(:wiki_page) { create(:wiki_page, container: container, content: content) }
shared_examples 'a page without front-matter' do
it { expect(wiki_page).to have_attributes(front_matter: {}, content: content) }
@@ -153,9 +142,9 @@ describe WikiPage do
it_behaves_like 'a page without front-matter'
- context 'but enabled for the project' do
+ context 'but enabled for the container' do
before do
- enable_front_matter_for_project
+ enable_front_matter_for(container)
end
it_behaves_like 'a page with front-matter'
@@ -344,7 +333,7 @@ describe WikiPage do
context 'with an existing page title exceeding the limit' do
subject do
title = 'a' * (max_title + 1)
- create_page(title, 'content')
+ wiki.create_page(title, 'content')
wiki.find_page(title)
end
@@ -388,6 +377,20 @@ describe WikiPage do
expect(wiki.find_page("Index").message).to eq 'Custom Commit Message'
end
+
+ it 'if the title is preceded by a / it is removed' do
+ subject.create(attributes.merge(title: '/New Page'))
+
+ expect(wiki.find_page('New Page')).not_to be_nil
+ end
+ end
+
+ context "with invalid attributes" do
+ it 'does not create the page' do
+ subject.create(title: '')
+
+ expect(wiki.find_page('New Page')).to be_nil
+ end
end
end
@@ -410,14 +413,11 @@ describe WikiPage do
end
end
- describe "#update" do
- subject do
- create_page(title, "content")
- wiki.find_page(title)
- end
+ describe '#update' do
+ subject { create(:wiki_page, wiki: wiki, title: title) }
- it "updates the content of the page" do
- subject.update(content: "new content")
+ it 'updates the content of the page' do
+ subject.update(content: 'new content')
page = wiki.find_page(title)
expect([subject.content, page.content]).to all(eq('new content'))
@@ -429,24 +429,6 @@ describe WikiPage do
end
end
- describe '#create' do
- context 'with valid attributes' do
- it 'raises an error if a page with the same path already exists' do
- create_page('New Page', 'content')
- create_page('foo/bar', 'content')
-
- expect { create_page('New Page', 'other content') }.to raise_error Gitlab::Git::Wiki::DuplicatePageError
- expect { create_page('foo/bar', 'other content') }.to raise_error Gitlab::Git::Wiki::DuplicatePageError
- end
-
- it 'if the title is preceded by a / it is removed' do
- create_page('/New Page', 'content')
-
- expect(wiki.find_page('New Page')).not_to be_nil
- end
- end
- end
-
describe "#update" do
subject { existing_page }
@@ -514,9 +496,9 @@ describe WikiPage do
expect([subject, page]).to all(have_attributes(front_matter: be_empty, content: content))
end
- context 'but it is enabled for the project' do
+ context 'but it is enabled for the container' do
before do
- enable_front_matter_for_project
+ enable_front_matter_for(container)
end
it_behaves_like 'able to update front-matter'
@@ -556,7 +538,7 @@ describe WikiPage do
context 'when renaming a page' do
it 'raises an error if the page already exists' do
- create_page('Existing Page', 'content')
+ wiki.create_page('Existing Page', 'content')
expect { subject.update(title: 'Existing Page', content: 'new_content') }.to raise_error(WikiPage::PageRenameError)
expect(subject.title).to eq 'test page'
@@ -578,7 +560,7 @@ describe WikiPage do
context 'when moving a page' do
it 'raises an error if the page already exists' do
- create_page('foo/Existing Page', 'content')
+ wiki.create_page('foo/Existing Page', 'content')
expect { subject.update(title: 'foo/Existing Page', content: 'new_content') }.to raise_error(WikiPage::PageRenameError)
expect(subject.title).to eq 'test page'
@@ -598,10 +580,7 @@ describe WikiPage do
end
context 'in subdir' do
- subject do
- create_page('foo/Existing Page', 'content')
- wiki.find_page('foo/Existing Page')
- end
+ subject { create(:wiki_page, wiki: wiki, title: 'foo/Existing Page') }
it 'moves the page to the root folder if the title is preceded by /' do
expect(subject.slug).to eq 'foo/Existing-Page'
@@ -639,7 +618,7 @@ describe WikiPage do
end
end
- describe "#destroy" do
+ describe "#delete" do
subject { existing_page }
it "deletes the page" do
@@ -671,10 +650,7 @@ describe WikiPage do
using RSpec::Parameterized::TableSyntax
let(:untitled_page) { described_class.new(wiki) }
- let(:directory_page) do
- create_page('parent directory/child page', 'test content')
- wiki.find_page('parent directory/child page')
- end
+ let(:directory_page) { create(:wiki_page, title: 'parent directory/child page') }
where(:page, :title, :changed) do
:untitled_page | nil | false
@@ -737,10 +713,7 @@ describe WikiPage do
end
context 'when the page is inside an actual directory' do
- subject do
- create_page('dir_1/dir_1_1/file', 'content')
- wiki.find_page('dir_1/dir_1_1/file')
- end
+ subject { create(:wiki_page, title: 'dir_1/dir_1_1/file') }
it 'returns the full directory hierarchy' do
expect(subject.directory).to eq('dir_1/dir_1_1')
@@ -787,6 +760,16 @@ describe WikiPage do
end
end
+ describe '#persisted?' do
+ it 'returns true for a persisted page' do
+ expect(existing_page).to be_persisted
+ end
+
+ it 'returns false for an unpersisted page' do
+ expect(new_page).not_to be_persisted
+ end
+ end
+
describe '#to_partial_path' do
it 'returns the relative path to the partial to be used' do
expect(subject.to_partial_path).to eq('projects/wikis/wiki_page')
@@ -812,23 +795,23 @@ describe WikiPage do
other_page = create(:wiki_page)
expect(subject.slug).not_to eq(other_page.slug)
- expect(subject.project).not_to eq(other_page.project)
+ expect(subject.container).not_to eq(other_page.container)
expect(subject).not_to eq(other_page)
end
- it 'returns false for page with different slug on same project' do
- other_page = create(:wiki_page, project: subject.project)
+ it 'returns false for page with different slug on same container' do
+ other_page = create(:wiki_page, container: subject.container)
expect(subject.slug).not_to eq(other_page.slug)
- expect(subject.project).to eq(other_page.project)
+ expect(subject.container).to eq(other_page.container)
expect(subject).not_to eq(other_page)
end
- it 'returns false for page with the same slug on a different project' do
+ it 'returns false for page with the same slug on a different container' do
other_page = create(:wiki_page, title: existing_page.slug)
expect(subject.slug).to eq(other_page.slug)
- expect(subject.project).not_to eq(other_page.project)
+ expect(subject.container).not_to eq(other_page.container)
expect(subject).not_to eq(other_page)
end
end
@@ -858,19 +841,21 @@ describe WikiPage do
end
end
- private
-
- def remove_temp_repo(path)
- FileUtils.rm_rf path
- end
+ describe '#version_commit_timestamp' do
+ context 'for a new page' do
+ it 'returns nil' do
+ expect(new_page.version_commit_timestamp).to be_nil
+ end
+ end
- def commit_details
- Gitlab::Git::Wiki::CommitDetails.new(user.id, user.username, user.name, user.email, "test commit")
+ context 'for page that exists' do
+ it 'returns the timestamp of the commit' do
+ expect(existing_page.version_commit_timestamp).to eq(existing_page.version.commit.committed_date)
+ end
+ end
end
- def create_page(name, content)
- wiki.wiki.write_page(name, :markdown, content, commit_details)
- end
+ private
def get_slugs(page_or_dir)
if page_or_dir.is_a? WikiPage
diff --git a/spec/models/x509_commit_signature_spec.rb b/spec/models/x509_commit_signature_spec.rb
index a2f72228a86..2efb77c96ad 100644
--- a/spec/models/x509_commit_signature_spec.rb
+++ b/spec/models/x509_commit_signature_spec.rb
@@ -9,6 +9,15 @@ RSpec.describe X509CommitSignature do
let(:x509_certificate) { create(:x509_certificate) }
let(:x509_signature) { create(:x509_commit_signature, commit_sha: commit_sha) }
+ let(:attributes) do
+ {
+ commit_sha: commit_sha,
+ project: project,
+ x509_certificate_id: x509_certificate.id,
+ verification_status: "verified"
+ }
+ end
+
it_behaves_like 'having unique enum values'
describe 'validation' do
@@ -23,15 +32,6 @@ RSpec.describe X509CommitSignature do
end
describe '.safe_create!' do
- let(:attributes) do
- {
- commit_sha: commit_sha,
- project: project,
- x509_certificate_id: x509_certificate.id,
- verification_status: "verified"
- }
- end
-
it 'finds a signature by commit sha if it existed' do
x509_signature
@@ -50,4 +50,18 @@ RSpec.describe X509CommitSignature do
expect(signature.x509_certificate_id).to eq(x509_certificate.id)
end
end
+
+ describe '#user' do
+ context 'if email is assigned to a user' do
+ let!(:user) { create(:user, email: X509Helpers::User1.certificate_email) }
+
+ it 'returns user' do
+ expect(described_class.safe_create!(attributes).user).to eq(user)
+ end
+ end
+
+ it 'if email is not assigned to a user, return nil' do
+ expect(described_class.safe_create!(attributes).user).to be_nil
+ end
+ end
end
diff --git a/spec/policies/alert_management/alert_policy_spec.rb b/spec/policies/alert_management/alert_policy_spec.rb
new file mode 100644
index 00000000000..0d7624a0142
--- /dev/null
+++ b/spec/policies/alert_management/alert_policy_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe AlertManagement::AlertPolicy, :models do
+ let(:alert) { create(:alert_management_alert) }
+ let(:project) { alert.project }
+ let(:user) { create(:user) }
+
+ subject(:policy) { described_class.new(user, alert) }
+
+ describe 'rules' do
+ it { is_expected.to be_disallowed :read_alert_management_alert }
+ it { is_expected.to be_disallowed :update_alert_management_alert }
+
+ context 'when developer' do
+ before do
+ project.add_developer(user)
+ end
+
+ it { is_expected.to be_allowed :read_alert_management_alert }
+ it { is_expected.to be_allowed :update_alert_management_alert }
+ end
+ end
+end
diff --git a/spec/policies/base_policy_spec.rb b/spec/policies/base_policy_spec.rb
index e15221492c3..67f7452528a 100644
--- a/spec/policies/base_policy_spec.rb
+++ b/spec/policies/base_policy_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-describe BasePolicy, :do_not_mock_admin_mode do
+describe BasePolicy do
include ExternalAuthorizationServiceHelpers
include AdminModeHelper
diff --git a/spec/policies/blob_policy_spec.rb b/spec/policies/blob_policy_spec.rb
index 20c8a55f437..e48dd751a8f 100644
--- a/spec/policies/blob_policy_spec.rb
+++ b/spec/policies/blob_policy_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-describe BlobPolicy do
+describe BlobPolicy, :enable_admin_mode do
include_context 'ProjectPolicyTable context'
include ProjectHelpers
using RSpec::Parameterized::TableSyntax
diff --git a/spec/policies/ci/build_policy_spec.rb b/spec/policies/ci/build_policy_spec.rb
index 333f4e560cf..f29ed26f2aa 100644
--- a/spec/policies/ci/build_policy_spec.rb
+++ b/spec/policies/ci/build_policy_spec.rb
@@ -176,15 +176,21 @@ describe Ci::BuildPolicy do
end
context 'when developers can push to the branch' do
- before do
- create(:protected_branch, :developers_can_push,
- name: build.ref, project: project)
- end
-
context 'when the build was created by the developer' do
let(:owner) { user }
- it { expect(policy).to be_allowed :erase_build }
+ context 'when the build was created for a protected ref' do
+ before do
+ create(:protected_branch, :developers_can_push,
+ name: build.ref, project: project)
+ end
+
+ it { expect(policy).to be_disallowed :erase_build }
+ end
+
+ context 'when the build was created for an unprotected ref' do
+ it { expect(policy).to be_allowed :erase_build }
+ end
end
context 'when the build was created by the other' do
diff --git a/spec/policies/clusters/cluster_policy_spec.rb b/spec/policies/clusters/cluster_policy_spec.rb
index 55c3351a171..26cfc19862a 100644
--- a/spec/policies/clusters/cluster_policy_spec.rb
+++ b/spec/policies/clusters/cluster_policy_spec.rb
@@ -80,8 +80,15 @@ describe Clusters::ClusterPolicy, :models do
context 'when admin' do
let(:user) { create(:admin) }
- it { expect(policy).to be_allowed :update_cluster }
- it { expect(policy).to be_allowed :admin_cluster }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { expect(policy).to be_allowed :update_cluster }
+ it { expect(policy).to be_allowed :admin_cluster }
+ end
+
+ context 'when admin mode is disabled' do
+ it { expect(policy).to be_disallowed :update_cluster }
+ it { expect(policy).to be_disallowed :admin_cluster }
+ end
end
end
end
diff --git a/spec/policies/clusters/instance_policy_spec.rb b/spec/policies/clusters/instance_policy_spec.rb
index 2373fef8aa6..dfe480d7fa4 100644
--- a/spec/policies/clusters/instance_policy_spec.rb
+++ b/spec/policies/clusters/instance_policy_spec.rb
@@ -18,11 +18,21 @@ describe Clusters::InstancePolicy do
context 'when admin' do
let(:user) { create(:admin) }
- it { expect(policy).to be_allowed :read_cluster }
- it { expect(policy).to be_allowed :add_cluster }
- it { expect(policy).to be_allowed :create_cluster }
- it { expect(policy).to be_allowed :update_cluster }
- it { expect(policy).to be_allowed :admin_cluster }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { expect(policy).to be_allowed :read_cluster }
+ it { expect(policy).to be_allowed :add_cluster }
+ it { expect(policy).to be_allowed :create_cluster }
+ it { expect(policy).to be_allowed :update_cluster }
+ it { expect(policy).to be_allowed :admin_cluster }
+ end
+
+ context 'when admin mode is disabled' do
+ it { expect(policy).to be_disallowed :read_cluster }
+ it { expect(policy).to be_disallowed :add_cluster }
+ it { expect(policy).to be_disallowed :create_cluster }
+ it { expect(policy).to be_disallowed :update_cluster }
+ it { expect(policy).to be_disallowed :admin_cluster }
+ end
end
end
end
diff --git a/spec/policies/deploy_key_policy_spec.rb b/spec/policies/deploy_key_policy_spec.rb
index aca93d8fe85..545647e2c67 100644
--- a/spec/policies/deploy_key_policy_spec.rb
+++ b/spec/policies/deploy_key_policy_spec.rb
@@ -42,16 +42,28 @@ describe DeployKeyPolicy do
context 'when an admin user' do
let(:current_user) { create(:user, :admin) }
- context ' tries to update private deploy key' do
+ context 'tries to update private deploy key' do
let(:deploy_key) { create(:deploy_key, public: false) }
- it { is_expected.to be_allowed(:update_deploy_key) }
+ context 'when admin mode enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:update_deploy_key) }
+ end
+
+ context 'when admin mode disabled' do
+ it { is_expected.to be_disallowed(:update_deploy_key) }
+ end
end
context 'when an admin user tries to update public deploy key' do
let(:deploy_key) { create(:another_deploy_key, public: true) }
- it { is_expected.to be_allowed(:update_deploy_key) }
+ context 'when admin mode enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:update_deploy_key) }
+ end
+
+ context 'when admin mode disabled' do
+ it { is_expected.to be_disallowed(:update_deploy_key) }
+ end
end
end
end
diff --git a/spec/policies/design_management/design_policy_spec.rb b/spec/policies/design_management/design_policy_spec.rb
new file mode 100644
index 00000000000..a566aecc4b7
--- /dev/null
+++ b/spec/policies/design_management/design_policy_spec.rb
@@ -0,0 +1,181 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe DesignManagement::DesignPolicy do
+ include DesignManagementTestHelpers
+
+ include_context 'ProjectPolicy context'
+
+ let(:guest_design_abilities) { %i[read_design] }
+ let(:developer_design_abilities) do
+ %i[create_design destroy_design]
+ end
+ let(:design_abilities) { guest_design_abilities + developer_design_abilities }
+
+ let(:issue) { create(:issue, project: project) }
+ let(:design) { create(:design, issue: issue) }
+
+ subject(:design_policy) { described_class.new(current_user, design) }
+
+ shared_examples_for "design abilities not available" do
+ context "for owners" do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_disallowed(*design_abilities) }
+ end
+
+ context "for admins" do
+ let(:current_user) { admin }
+
+ it { is_expected.to be_disallowed(*design_abilities) }
+ end
+
+ context "for maintainers" do
+ let(:current_user) { maintainer }
+
+ it { is_expected.to be_disallowed(*design_abilities) }
+ end
+
+ context "for developers" do
+ let(:current_user) { developer }
+
+ it { is_expected.to be_disallowed(*design_abilities) }
+ end
+
+ context "for reporters" do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_disallowed(*design_abilities) }
+ end
+
+ context "for guests" do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_disallowed(*design_abilities) }
+ end
+
+ context "for anonymous users" do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_disallowed(*design_abilities) }
+ end
+ end
+
+ shared_examples_for "design abilities available for members" do
+ context "for owners" do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_allowed(*design_abilities) }
+ end
+
+ context "for admins" do
+ let(:current_user) { admin }
+
+ context 'when admin mode enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(*design_abilities) }
+ end
+
+ context 'when admin mode disabled' do
+ it { is_expected.to be_allowed(*guest_design_abilities) }
+ it { is_expected.to be_disallowed(*developer_design_abilities) }
+ end
+ end
+
+ context "for maintainers" do
+ let(:current_user) { maintainer }
+
+ it { is_expected.to be_allowed(*design_abilities) }
+ end
+
+ context "for developers" do
+ let(:current_user) { developer }
+
+ it { is_expected.to be_allowed(*design_abilities) }
+ end
+
+ context "for reporters" do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_allowed(*guest_design_abilities) }
+ it { is_expected.to be_disallowed(*developer_design_abilities) }
+ end
+ end
+
+ shared_examples_for "read-only design abilities" do
+ it { is_expected.to be_allowed(:read_design) }
+ it { is_expected.to be_disallowed(:create_design, :destroy_design) }
+ end
+
+ context "when DesignManagement is not enabled" do
+ before do
+ enable_design_management(false)
+ end
+
+ it_behaves_like "design abilities not available"
+ end
+
+ context "when the feature is available" do
+ before do
+ enable_design_management
+ end
+
+ it_behaves_like "design abilities available for members"
+
+ context "for guests in private projects" do
+ let(:project) { create(:project, :private) }
+ let(:current_user) { guest }
+
+ it { is_expected.to be_allowed(*guest_design_abilities) }
+ it { is_expected.to be_disallowed(*developer_design_abilities) }
+ end
+
+ context "for anonymous users in public projects" do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_allowed(*guest_design_abilities) }
+ it { is_expected.to be_disallowed(*developer_design_abilities) }
+ end
+
+ context "when the issue is confidential" do
+ let(:issue) { create(:issue, :confidential, project: project) }
+
+ it_behaves_like "design abilities available for members"
+
+ context "for guests" do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_disallowed(*design_abilities) }
+ end
+
+ context "for anonymous users" do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_disallowed(*design_abilities) }
+ end
+ end
+
+ context "when the issue is locked" do
+ let(:current_user) { owner }
+ let(:issue) { create(:issue, :locked, project: project) }
+
+ it_behaves_like "read-only design abilities"
+ end
+
+ context "when the issue has moved" do
+ let(:current_user) { owner }
+ let(:issue) { create(:issue, project: project, moved_to: create(:issue)) }
+
+ it_behaves_like "read-only design abilities"
+ end
+
+ context "when the project is archived" do
+ let(:current_user) { owner }
+
+ before do
+ project.update!(archived: true)
+ end
+
+ it_behaves_like "read-only design abilities"
+ end
+ end
+end
diff --git a/spec/policies/environment_policy_spec.rb b/spec/policies/environment_policy_spec.rb
index a098b52023d..75fca464ec8 100644
--- a/spec/policies/environment_policy_spec.rb
+++ b/spec/policies/environment_policy_spec.rb
@@ -37,7 +37,13 @@ describe EnvironmentPolicy do
context 'when an admin user' do
let(:user) { create(:user, :admin) }
- it { expect(policy).to be_allowed :stop_environment }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { expect(policy).to be_allowed :stop_environment }
+ end
+
+ context 'when admin mode is disabled' do
+ it { expect(policy).to be_disallowed :stop_environment }
+ end
end
context 'with protected branch' do
@@ -54,7 +60,13 @@ describe EnvironmentPolicy do
context 'when an admin user' do
let(:user) { create(:user, :admin) }
- it { expect(policy).to be_allowed :stop_environment }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { expect(policy).to be_allowed :stop_environment }
+ end
+
+ context 'when admin mode is disabled' do
+ it { expect(policy).to be_disallowed :stop_environment }
+ end
end
end
end
@@ -83,7 +95,13 @@ describe EnvironmentPolicy do
context 'when an admin user' do
let(:user) { create(:user, :admin) }
- it { expect(policy).to be_allowed :stop_environment }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { expect(policy).to be_allowed :stop_environment }
+ end
+
+ context 'when admin mode is disabled' do
+ it { expect(policy).to be_disallowed :stop_environment }
+ end
end
end
@@ -126,7 +144,13 @@ describe EnvironmentPolicy do
environment.stop!
end
- it { expect(policy).to be_allowed :destroy_environment }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { expect(policy).to be_allowed :destroy_environment }
+ end
+
+ context 'when admin mode is disabled' do
+ it { expect(policy).to be_disallowed :destroy_environment }
+ end
end
end
end
diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb
index 5e77b64a408..e8ba4eed4ec 100644
--- a/spec/policies/global_policy_spec.rb
+++ b/spec/policies/global_policy_spec.rb
@@ -6,6 +6,7 @@ describe GlobalPolicy do
include TermsHelper
let_it_be(:project_bot) { create(:user, :project_bot) }
+ let_it_be(:migration_bot) { create(:user, :migration_bot) }
let(:current_user) { create(:user) }
let(:user) { create(:user) }
@@ -80,6 +81,34 @@ describe GlobalPolicy do
end
end
+ describe 'create group' do
+ context 'when user has the ability to create group' do
+ let(:current_user) { create(:user, can_create_group: true) }
+
+ it { is_expected.to be_allowed(:create_group) }
+ end
+
+ context 'when user does not have the ability to create group' do
+ let(:current_user) { create(:user, can_create_group: false) }
+
+ it { is_expected.not_to be_allowed(:create_group) }
+ end
+ end
+
+ describe 'create group with default branch protection' do
+ context 'when user has the ability to create group' do
+ let(:current_user) { create(:user, can_create_group: true) }
+
+ it { is_expected.to be_allowed(:create_group_with_default_branch_protection) }
+ end
+
+ context 'when user does not have the ability to create group' do
+ let(:current_user) { create(:user, can_create_group: false) }
+
+ it { is_expected.not_to be_allowed(:create_group_with_default_branch_protection) }
+ end
+ end
+
describe 'custom attributes' do
context 'regular user' do
it { is_expected.not_to be_allowed(:read_custom_attribute) }
@@ -89,8 +118,15 @@ describe GlobalPolicy do
context 'admin' do
let(:current_user) { create(:user, :admin) }
- it { is_expected.to be_allowed(:read_custom_attribute) }
- it { is_expected.to be_allowed(:update_custom_attribute) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:read_custom_attribute) }
+ it { is_expected.to be_allowed(:update_custom_attribute) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_disallowed(:read_custom_attribute) }
+ it { is_expected.to be_disallowed(:update_custom_attribute) }
+ end
end
end
@@ -127,6 +163,12 @@ describe GlobalPolicy do
it { is_expected.to be_allowed(:access_api) }
end
+ context 'migration bot' do
+ let(:current_user) { migration_bot }
+
+ it { is_expected.not_to be_allowed(:access_api) }
+ end
+
context 'when terms are enforced' do
before do
enforce_terms
@@ -216,6 +258,12 @@ describe GlobalPolicy do
it { is_expected.not_to be_allowed(:receive_notifications) }
end
+
+ context 'migration bot' do
+ let(:current_user) { migration_bot }
+
+ it { is_expected.not_to be_allowed(:receive_notifications) }
+ end
end
describe 'git access' do
@@ -235,6 +283,12 @@ describe GlobalPolicy do
it { is_expected.to be_allowed(:access_git) }
end
+ context 'migration bot' do
+ let(:current_user) { migration_bot }
+
+ it { is_expected.to be_allowed(:access_git) }
+ end
+
describe 'deactivated user' do
before do
current_user.deactivate
@@ -321,7 +375,13 @@ describe GlobalPolicy do
stub_application_setting(instance_statistics_visibility_private: true)
end
- it { is_expected.to be_allowed(:read_instance_statistics) }
+ 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
@@ -386,6 +446,12 @@ describe GlobalPolicy do
it { is_expected.to be_allowed(:use_slash_commands) }
end
+
+ context 'migration bot' do
+ let(:current_user) { migration_bot }
+
+ it { is_expected.not_to be_allowed(:use_slash_commands) }
+ end
end
describe 'create_snippet' do
@@ -412,5 +478,11 @@ describe GlobalPolicy do
it { is_expected.not_to be_allowed(:log_in) }
end
+
+ context 'migration bot' do
+ let(:current_user) { migration_bot }
+
+ it { is_expected.not_to be_allowed(:log_in) }
+ end
end
end
diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb
index 13f1bcb389a..9faddfd00e5 100644
--- a/spec/policies/group_policy_spec.rb
+++ b/spec/policies/group_policy_spec.rb
@@ -644,7 +644,13 @@ describe GroupPolicy do
context 'admin' do
let(:current_user) { admin }
- it { expect_allowed(:update_max_artifacts_size) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { expect_allowed(:update_max_artifacts_size) }
+ end
+
+ context 'when admin mode is enabled' do
+ it { expect_disallowed(:update_max_artifacts_size) }
+ end
end
%w(guest reporter developer maintainer owner).each do |role|
@@ -655,26 +661,4 @@ describe GroupPolicy do
end
end
end
-
- it_behaves_like 'model with wiki policies' do
- let(:container) { create(:group) }
-
- def set_access_level(access_level)
- allow(container).to receive(:wiki_access_level).and_return(access_level)
- end
-
- before do
- stub_feature_flags(group_wiki: true)
- end
-
- context 'when the feature flag is disabled' do
- before do
- stub_feature_flags(group_wiki: false)
- end
-
- it 'does not include the wiki permissions' do
- expect_disallowed(*permissions)
- end
- end
- end
end
diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb
index 242a002bc23..9d52079e4be 100644
--- a/spec/policies/issue_policy_spec.rb
+++ b/spec/policies/issue_policy_spec.rb
@@ -206,11 +206,25 @@ describe IssuePolicy do
it 'allows guests to comment' do
expect(permissions(guest, issue)).to be_allowed(:create_note)
end
- it 'allows admins to view' do
- expect(permissions(admin, issue)).to be_allowed(:read_issue)
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'allows admins to view' do
+ expect(permissions(admin, issue)).to be_allowed(:read_issue)
+ end
+
+ it 'allows admins to comment' do
+ expect(permissions(admin, issue)).to be_allowed(:create_note)
+ end
end
- it 'allows admins to comment' do
- expect(permissions(admin, issue)).to be_allowed(:create_note)
+
+ context 'when admin mode is disabled' do
+ it 'forbids admins to view' do
+ expect(permissions(admin, issue)).to be_disallowed(:read_issue)
+ end
+
+ it 'forbids admins to comment' do
+ expect(permissions(admin, issue)).to be_disallowed(:create_note)
+ end
end
end
diff --git a/spec/policies/merge_request_policy_spec.rb b/spec/policies/merge_request_policy_spec.rb
index 287325e96df..31ced5db953 100644
--- a/spec/policies/merge_request_policy_spec.rb
+++ b/spec/policies/merge_request_policy_spec.rb
@@ -21,7 +21,7 @@ describe MergeRequestPolicy do
project.add_developer(developer)
end
- MR_PERMS = %i[create_merge_request_in
+ mr_perms = %i[create_merge_request_in
create_merge_request_from
read_merge_request
create_note].freeze
@@ -29,7 +29,7 @@ describe MergeRequestPolicy do
shared_examples_for 'a denied user' do
let(:perms) { permissions(subject, merge_request) }
- MR_PERMS.each do |thing|
+ mr_perms.each do |thing|
it "cannot #{thing}" do
expect(perms).to be_disallowed(thing)
end
@@ -39,7 +39,7 @@ describe MergeRequestPolicy do
shared_examples_for 'a user with access' do
let(:perms) { permissions(subject, merge_request) }
- MR_PERMS.each do |thing|
+ mr_perms.each do |thing|
it "can #{thing}" do
expect(perms).to be_allowed(thing)
end
diff --git a/spec/policies/namespace_policy_spec.rb b/spec/policies/namespace_policy_spec.rb
index c0a5119c550..01162dc0fc4 100644
--- a/spec/policies/namespace_policy_spec.rb
+++ b/spec/policies/namespace_policy_spec.rb
@@ -40,6 +40,12 @@ describe NamespacePolicy do
context 'admin' do
let(:current_user) { admin }
- it { is_expected.to be_allowed(*owner_permissions) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(*owner_permissions) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_disallowed(*owner_permissions) }
+ end
end
end
diff --git a/spec/policies/note_policy_spec.rb b/spec/policies/note_policy_spec.rb
index e9dd5ee1c51..1e3bd0d9147 100644
--- a/spec/policies/note_policy_spec.rb
+++ b/spec/policies/note_policy_spec.rb
@@ -295,8 +295,16 @@ describe NotePolicy do
expect(permissions(maintainer, confidential_note)).to be_allowed(:read_note, :admin_note, :resolve_note, :award_emoji)
end
- it 'allows admins to read all notes and admin them' do
- expect(permissions(admin, confidential_note)).to be_allowed(:read_note, :admin_note, :resolve_note, :award_emoji)
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'allows admins to read all notes and admin them' do
+ expect(permissions(admin, confidential_note)).to be_allowed(:read_note, :admin_note, :resolve_note, :award_emoji)
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'does not allow non members to read confidential notes and replies' do
+ expect(permissions(admin, confidential_note)).to be_disallowed(:read_note, :admin_note, :resolve_note, :award_emoji)
+ end
end
it 'allows noteable author to read and resolve all notes' do
diff --git a/spec/policies/personal_snippet_policy_spec.rb b/spec/policies/personal_snippet_policy_spec.rb
index a6b76620c29..5fc48717d86 100644
--- a/spec/policies/personal_snippet_policy_spec.rb
+++ b/spec/policies/personal_snippet_policy_spec.rb
@@ -19,8 +19,8 @@ describe PersonalSnippetPolicy do
described_class.new(user, snippet)
end
- shared_examples 'admin access' do
- context 'admin user' do
+ shared_examples 'admin access with admin mode' do
+ context 'admin user', :enable_admin_mode do
subject { permissions(admin_user) }
it do
@@ -68,7 +68,7 @@ describe PersonalSnippetPolicy do
end
end
- it_behaves_like 'admin access'
+ it_behaves_like 'admin access with admin mode'
end
context 'internal snippet' do
@@ -118,7 +118,7 @@ describe PersonalSnippetPolicy do
end
end
- it_behaves_like 'admin access'
+ it_behaves_like 'admin access with admin mode'
end
context 'private snippet' do
@@ -168,6 +168,6 @@ describe PersonalSnippetPolicy do
end
end
- it_behaves_like 'admin access'
+ it_behaves_like 'admin access with admin mode'
end
end
diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb
index db643e3a31f..09d54eb9df6 100644
--- a/spec/policies/project_policy_spec.rb
+++ b/spec/policies/project_policy_spec.rb
@@ -29,6 +29,7 @@ describe ProjectPolicy do
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
]
end
@@ -41,7 +42,7 @@ describe ProjectPolicy do
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
+ 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
]
@@ -53,7 +54,7 @@ describe ProjectPolicy do
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
- daily_statistics read_deploy_token create_deploy_token destroy_deploy_token
+ read_deploy_token create_deploy_token destroy_deploy_token
admin_terraform_state
]
end
@@ -123,6 +124,7 @@ describe ProjectPolicy do
it_behaves_like 'model with wiki policies' do
let(:container) { project }
+ let_it_be(:user) { owner }
def set_access_level(access_level)
project.project_feature.update_attribute(:wiki_access_level, access_level)
@@ -216,16 +218,41 @@ describe ProjectPolicy do
project.project_feature.update(builds_access_level: ProjectFeature::DISABLED)
end
- it 'disallows all permissions except pipeline when the feature is disabled' do
- builds_permissions = [
- :create_build, :read_build, :update_build, :admin_build, :destroy_build,
- :create_pipeline_schedule, :read_pipeline_schedule, :update_pipeline_schedule, :admin_pipeline_schedule, :destroy_pipeline_schedule,
- :create_environment, :read_environment, :update_environment, :admin_environment, :destroy_environment,
- :create_cluster, :read_cluster, :update_cluster, :admin_cluster, :destroy_cluster,
- :create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment
- ]
+ context 'without metrics_dashboard_allowed' do
+ before do
+ project.project_feature.update(metrics_dashboard_access_level: ProjectFeature::DISABLED)
+ end
- expect_disallowed(*builds_permissions)
+ it 'disallows all permissions except pipeline when the feature is disabled' do
+ builds_permissions = [
+ :create_build, :read_build, :update_build, :admin_build, :destroy_build,
+ :create_pipeline_schedule, :read_pipeline_schedule, :update_pipeline_schedule, :admin_pipeline_schedule, :destroy_pipeline_schedule,
+ :create_environment, :read_environment, :update_environment, :admin_environment, :destroy_environment,
+ :create_cluster, :read_cluster, :update_cluster, :admin_cluster, :destroy_cluster,
+ :create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment
+ ]
+
+ expect_disallowed(*builds_permissions)
+ end
+ end
+
+ context 'with metrics_dashboard_allowed' do
+ before do
+ project.project_feature.update(metrics_dashboard_access_level: ProjectFeature::ENABLED)
+ end
+
+ it 'disallows all permissions except pipeline and read_environment when the feature is disabled' do
+ builds_permissions = [
+ :create_build, :read_build, :update_build, :admin_build, :destroy_build,
+ :create_pipeline_schedule, :read_pipeline_schedule, :update_pipeline_schedule, :admin_pipeline_schedule, :destroy_pipeline_schedule,
+ :create_environment, :update_environment, :admin_environment, :destroy_environment,
+ :create_cluster, :read_cluster, :update_cluster, :admin_cluster, :destroy_cluster,
+ :create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment
+ ]
+
+ expect_disallowed(*builds_permissions)
+ expect_allowed(:read_environment)
+ end
end
end
@@ -250,20 +277,49 @@ describe ProjectPolicy do
context 'repository feature' do
subject { described_class.new(owner, project) }
- it 'disallows all permissions when the feature is disabled' do
+ before do
project.project_feature.update(repository_access_level: ProjectFeature::DISABLED)
+ end
- repository_permissions = [
- :create_pipeline, :update_pipeline, :admin_pipeline, :destroy_pipeline,
- :create_build, :read_build, :update_build, :admin_build, :destroy_build,
- :create_pipeline_schedule, :read_pipeline_schedule, :update_pipeline_schedule, :admin_pipeline_schedule, :destroy_pipeline_schedule,
- :create_environment, :read_environment, :update_environment, :admin_environment, :destroy_environment,
- :create_cluster, :read_cluster, :update_cluster, :admin_cluster,
- :create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment,
- :destroy_release
- ]
+ context 'without metrics_dashboard_allowed' do
+ before do
+ project.project_feature.update(metrics_dashboard_access_level: ProjectFeature::DISABLED)
+ end
- expect_disallowed(*repository_permissions)
+ it 'disallows all permissions when the feature is disabled' do
+ repository_permissions = [
+ :create_pipeline, :update_pipeline, :admin_pipeline, :destroy_pipeline,
+ :create_build, :read_build, :update_build, :admin_build, :destroy_build,
+ :create_pipeline_schedule, :read_pipeline_schedule, :update_pipeline_schedule, :admin_pipeline_schedule, :destroy_pipeline_schedule,
+ :create_environment, :read_environment, :update_environment, :admin_environment, :destroy_environment,
+ :create_cluster, :read_cluster, :update_cluster, :admin_cluster,
+ :create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment,
+ :destroy_release
+ ]
+
+ expect_disallowed(*repository_permissions)
+ end
+ end
+
+ context 'with metrics_dashboard_allowed' do
+ before do
+ project.project_feature.update(metrics_dashboard_access_level: ProjectFeature::ENABLED)
+ end
+
+ it 'disallows all permissions when the feature is disabled' do
+ repository_permissions = [
+ :create_pipeline, :update_pipeline, :admin_pipeline, :destroy_pipeline,
+ :create_build, :read_build, :update_build, :admin_build, :destroy_build,
+ :create_pipeline_schedule, :read_pipeline_schedule, :update_pipeline_schedule, :admin_pipeline_schedule, :destroy_pipeline_schedule,
+ :create_environment, :update_environment, :admin_environment, :destroy_environment,
+ :create_cluster, :read_cluster, :update_cluster, :admin_cluster,
+ :create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment,
+ :destroy_release
+ ]
+
+ expect_disallowed(*repository_permissions)
+ expect_allowed(:read_environment)
+ end
end
end
@@ -273,7 +329,8 @@ describe ProjectPolicy do
it_behaves_like 'project policies as developer'
it_behaves_like 'project policies as maintainer'
it_behaves_like 'project policies as owner'
- it_behaves_like 'project policies as admin'
+ it_behaves_like 'project policies as admin with admin mode'
+ it_behaves_like 'project policies as admin without admin mode'
context 'when a public project has merge requests allowing access' do
include ProjectForksHelper
@@ -304,7 +361,7 @@ describe ProjectPolicy do
expect_allowed(*maintainer_abilities)
end
- it 'dissallows abilities to a maintainer if the merge request was closed' do
+ it 'disallows abilities to a maintainer if the merge request was closed' do
target_project.add_developer(user)
merge_request.close!
@@ -348,10 +405,24 @@ describe ProjectPolicy do
expect(described_class.new(developer, project)).to be_allowed(:read_project)
end
- it 'does not check the external service for admins and allows access' do
- expect(::Gitlab::ExternalAuthorization).not_to receive(:access_allowed?)
+ context 'with an admin' do
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'does not check the external service and allows access' do
+ expect(::Gitlab::ExternalAuthorization).not_to receive(:access_allowed?)
- expect(described_class.new(admin, project)).to be_allowed(:read_project)
+ expect(described_class.new(admin, project)).to be_allowed(:read_project)
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'checks the external service and allows access' do
+ external_service_allow_access(admin, project)
+
+ expect(::Gitlab::ExternalAuthorization).to receive(:access_allowed?)
+
+ expect(described_class.new(admin, project)).to be_allowed(:read_project)
+ end
+ end
end
it 'prevents all but seeing a public project in a list when access is denied' do
@@ -414,7 +485,13 @@ describe ProjectPolicy do
context 'admin' do
let(:current_user) { admin }
- it { expect_allowed(:update_max_artifacts_size) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { expect_allowed(:update_max_artifacts_size) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { expect_disallowed(:update_max_artifacts_size) }
+ end
end
%w(guest reporter developer maintainer owner).each do |role|
@@ -446,7 +523,13 @@ describe ProjectPolicy do
context 'with admin' do
let(:current_user) { admin }
- it { is_expected.to be_allowed(:read_prometheus_alerts) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:read_prometheus_alerts) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_disallowed(:read_prometheus_alerts) }
+ end
end
context 'with owner' do
@@ -485,4 +568,232 @@ describe ProjectPolicy do
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) }
+
+ context 'feature private' do
+ context 'with reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_allowed(:metrics_dashboard) }
+ it { is_expected.to be_allowed(:read_prometheus) }
+ it { is_expected.to be_allowed(:read_deployment) }
+ it { is_expected.to be_allowed(:read_metrics_user_starred_dashboard) }
+ it { is_expected.to be_allowed(:create_metrics_user_starred_dashboard) }
+ end
+
+ context 'with guest' do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_disallowed(:metrics_dashboard) }
+ end
+
+ context 'with anonymous' do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_disallowed(:metrics_dashboard) }
+ end
+ end
+
+ context 'feature enabled' do
+ before do
+ project.project_feature.update(metrics_dashboard_access_level: ProjectFeature::ENABLED)
+ end
+
+ context 'with reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_allowed(:metrics_dashboard) }
+ it { is_expected.to be_allowed(:read_prometheus) }
+ it { is_expected.to be_allowed(:read_deployment) }
+ it { is_expected.to be_allowed(:read_metrics_user_starred_dashboard) }
+ it { is_expected.to be_allowed(:create_metrics_user_starred_dashboard) }
+ end
+
+ context 'with guest' do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_allowed(:metrics_dashboard) }
+ it { is_expected.to be_allowed(:read_prometheus) }
+ it { is_expected.to be_allowed(:read_deployment) }
+ it { is_expected.to be_allowed(:read_metrics_user_starred_dashboard) }
+ it { is_expected.to be_allowed(:create_metrics_user_starred_dashboard) }
+ end
+
+ context 'with anonymous' do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_allowed(:metrics_dashboard) }
+ it { is_expected.to be_allowed(:read_prometheus) }
+ it { is_expected.to be_allowed(:read_deployment) }
+ it { is_expected.to be_disallowed(:read_metrics_user_starred_dashboard) }
+ it { is_expected.to be_disallowed(:create_metrics_user_starred_dashboard) }
+ end
+ end
+ end
+
+ context 'internal project' do
+ let(:project) { create(:project, :internal) }
+
+ context 'feature private' do
+ context 'with reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_allowed(:metrics_dashboard) }
+ it { is_expected.to be_allowed(:read_prometheus) }
+ it { is_expected.to be_allowed(:read_deployment) }
+ it { is_expected.to be_allowed(:read_metrics_user_starred_dashboard) }
+ it { is_expected.to be_allowed(:create_metrics_user_starred_dashboard) }
+ end
+
+ context 'with guest' do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_disallowed(:metrics_dashboard) }
+ end
+
+ context 'with anonymous' do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_disallowed(:metrics_dashboard)}
+ end
+ end
+
+ context 'feature enabled' do
+ before do
+ project.project_feature.update(metrics_dashboard_access_level: ProjectFeature::ENABLED)
+ end
+
+ context 'with reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_allowed(:metrics_dashboard) }
+ it { is_expected.to be_allowed(:read_prometheus) }
+ it { is_expected.to be_allowed(:read_deployment) }
+ it { is_expected.to be_allowed(:read_metrics_user_starred_dashboard) }
+ it { is_expected.to be_allowed(:create_metrics_user_starred_dashboard) }
+ end
+
+ context 'with guest' do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_allowed(:metrics_dashboard) }
+ it { is_expected.to be_allowed(:read_prometheus) }
+ it { is_expected.to be_allowed(:read_deployment) }
+ it { is_expected.to be_allowed(:read_metrics_user_starred_dashboard) }
+ it { is_expected.to be_allowed(:create_metrics_user_starred_dashboard) }
+ end
+
+ context 'with anonymous' do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_disallowed(:metrics_dashboard) }
+ end
+ end
+ end
+
+ context 'private project' do
+ let(:project) { create(:project, :private) }
+
+ context 'feature private' do
+ context 'with reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_allowed(:metrics_dashboard) }
+ it { is_expected.to be_allowed(:read_prometheus) }
+ it { is_expected.to be_allowed(:read_deployment) }
+ it { is_expected.to be_allowed(:read_metrics_user_starred_dashboard) }
+ it { is_expected.to be_allowed(:create_metrics_user_starred_dashboard) }
+ end
+
+ context 'with guest' do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_disallowed(:metrics_dashboard) }
+ end
+
+ context 'with anonymous' do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_disallowed(:metrics_dashboard) }
+ end
+ end
+
+ context 'feature enabled' do
+ context 'with reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_allowed(:metrics_dashboard) }
+ it { is_expected.to be_allowed(:read_prometheus) }
+ it { is_expected.to be_allowed(:read_deployment) }
+ it { is_expected.to be_allowed(:read_metrics_user_starred_dashboard) }
+ it { is_expected.to be_allowed(:create_metrics_user_starred_dashboard) }
+ end
+
+ context 'with guest' do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_disallowed(:metrics_dashboard) }
+ end
+
+ context 'with anonymous' do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_disallowed(:metrics_dashboard) }
+ end
+ end
+ end
+
+ context 'feature disabled' do
+ before do
+ project.project_feature.update(metrics_dashboard_access_level: ProjectFeature::DISABLED)
+ end
+
+ context 'with reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_disallowed(:metrics_dashboard) }
+ end
+
+ context 'with guest' do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_disallowed(:metrics_dashboard) }
+ end
+
+ context 'with anonymous' do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_disallowed(:metrics_dashboard) }
+ end
+ end
+ end
+
+ context 'deploy token access' do
+ let!(:project_deploy_token) do
+ create(:project_deploy_token, project: project, deploy_token: deploy_token)
+ end
+
+ subject { described_class.new(deploy_token, project) }
+
+ context 'a deploy token with read_package_registry scope' do
+ let(:deploy_token) { create(:deploy_token, read_package_registry: true) }
+
+ it { is_expected.to be_allowed(:read_package) }
+ it { is_expected.to be_allowed(:read_project) }
+ it { is_expected.to be_disallowed(:create_package) }
+ end
+
+ context 'a deploy token with write_package_registry scope' do
+ let(:deploy_token) { create(:deploy_token, write_package_registry: true) }
+
+ it { is_expected.to be_allowed(:create_package) }
+ it { is_expected.to be_allowed(:read_project) }
+ it { is_expected.to be_disallowed(:destroy_package) }
+ end
+ end
end
diff --git a/spec/policies/project_snippet_policy_spec.rb b/spec/policies/project_snippet_policy_spec.rb
index c5077e119bc..3864666f587 100644
--- a/spec/policies/project_snippet_policy_spec.rb
+++ b/spec/policies/project_snippet_policy_spec.rb
@@ -235,9 +235,18 @@ describe ProjectSnippetPolicy do
let(:snippet_visibility) { :private }
let(:current_user) { create(:admin) }
- it do
- expect_allowed(:read_snippet, :create_note)
- expect_allowed(*author_permissions)
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it do
+ expect_allowed(:read_snippet, :create_note)
+ expect_allowed(*author_permissions)
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it do
+ expect_disallowed(:read_snippet, :create_note)
+ expect_disallowed(*author_permissions)
+ end
end
end
end
diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb
index 9da9d2ce49b..63c4bd05836 100644
--- a/spec/policies/user_policy_spec.rb
+++ b/spec/policies/user_policy_spec.rb
@@ -26,7 +26,13 @@ describe UserPolicy do
context "when an admin user tries to destroy a regular user" do
let(:current_user) { create(:user, :admin) }
- it { is_expected.to be_allowed(ability) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(ability) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_disallowed(ability) }
+ end
end
context "when an admin user tries to destroy a ghost user" do
diff --git a/spec/policies/wiki_page_policy_spec.rb b/spec/policies/wiki_page_policy_spec.rb
index e550ccf6d65..0dedccb6e88 100644
--- a/spec/policies/wiki_page_policy_spec.rb
+++ b/spec/policies/wiki_page_policy_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-describe WikiPagePolicy do
+describe WikiPagePolicy, :enable_admin_mode do
include_context 'ProjectPolicyTable context'
include ProjectHelpers
using RSpec::Parameterized::TableSyntax
diff --git a/spec/presenters/ci/build_presenter_spec.rb b/spec/presenters/ci/build_presenter_spec.rb
index b6c47f40ceb..9cf6eb45c63 100644
--- a/spec/presenters/ci/build_presenter_spec.rb
+++ b/spec/presenters/ci/build_presenter_spec.rb
@@ -264,30 +264,4 @@ describe Ci::BuildPresenter do
expect(description).to eq('There has been an API failure, please try again')
end
end
-
- describe '#recoverable?' do
- let(:build) { create(:ci_build, :failed, :script_failure) }
-
- context 'when is a script or missing dependency failure' do
- let(:failure_reasons) { %w(script_failure missing_dependency_failure archived_failure scheduler_failure data_integrity_failure) }
-
- it 'returns false' do
- failure_reasons.each do |failure_reason|
- build.update_attribute(:failure_reason, failure_reason)
- expect(presenter.recoverable?).to be_falsy
- end
- end
- end
-
- context 'when is any other failure type' do
- let(:failure_reasons) { %w(unknown_failure api_failure stuck_or_timeout_failure runner_system_failure) }
-
- it 'returns true' do
- failure_reasons.each do |failure_reason|
- build.update_attribute(:failure_reason, failure_reason)
- expect(presenter.recoverable?).to be_truthy
- end
- end
- end
- end
end
diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb
index 0635c318942..de199d2bff9 100644
--- a/spec/presenters/ci/build_runner_presenter_spec.rb
+++ b/spec/presenters/ci/build_runner_presenter_spec.rb
@@ -38,6 +38,47 @@ describe Ci::BuildRunnerPresenter do
expect(presenter.artifacts).to be_empty
end
end
+
+ context 'when artifacts exclude is defined' do
+ let(:build) do
+ create(:ci_build, options: { artifacts: { paths: %w[abc], exclude: %w[cde] } })
+ end
+
+ context 'when the feature is enabled' do
+ before do
+ stub_feature_flags(ci_artifacts_exclude: true)
+ end
+
+ it 'includes the list of excluded paths' do
+ expect(presenter.artifacts.first).to include(
+ artifact_type: :archive,
+ artifact_format: :zip,
+ paths: %w[abc],
+ exclude: %w[cde]
+ )
+ end
+ end
+
+ context 'when the feature is disabled' do
+ before do
+ stub_feature_flags(ci_artifacts_exclude: false)
+ end
+
+ it 'does not include the list of excluded paths' do
+ expect(presenter.artifacts.first).not_to have_key(:exclude)
+ end
+ end
+ end
+
+ context 'when artifacts exclude is not defined' do
+ let(:build) do
+ create(:ci_build, options: { artifacts: { paths: %w[abc] } })
+ end
+
+ it 'does not include an empty list of excluded paths' do
+ expect(presenter.artifacts.first).not_to have_key(:exclude)
+ end
+ end
end
context "with reports" do
@@ -138,32 +179,25 @@ describe Ci::BuildRunnerPresenter do
it 'defaults to git depth setting for the project' do
expect(git_depth).to eq(build.project.ci_default_git_depth)
end
-
- context 'when feature flag :ci_project_git_depth is disabled' do
- before do
- stub_feature_flags(ci_project_git_depth: { enabled: false })
- end
-
- it 'defaults to 0' do
- expect(git_depth).to eq(0)
- end
- end
end
describe '#refspecs' do
subject { presenter.refspecs }
let(:build) { create(:ci_build) }
+ let(:pipeline) { build.pipeline }
it 'returns the correct refspecs' do
- is_expected.to contain_exactly("+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}")
+ is_expected.to contain_exactly("+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}",
+ "+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}")
end
context 'when ref is tag' do
let(:build) { create(:ci_build, :tag) }
it 'returns the correct refspecs' do
- is_expected.to contain_exactly("+refs/tags/#{build.ref}:refs/tags/#{build.ref}")
+ is_expected.to contain_exactly("+refs/tags/#{build.ref}:refs/tags/#{build.ref}",
+ "+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}")
end
context 'when GIT_DEPTH is zero' do
@@ -173,7 +207,8 @@ describe Ci::BuildRunnerPresenter do
it 'returns the correct refspecs' do
is_expected.to contain_exactly('+refs/tags/*:refs/tags/*',
- '+refs/heads/*:refs/remotes/origin/*')
+ '+refs/heads/*:refs/remotes/origin/*',
+ "+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}")
end
end
end
@@ -183,81 +218,34 @@ describe Ci::BuildRunnerPresenter do
let(:pipeline) { merge_request.all_pipelines.first }
let(:build) { create(:ci_build, ref: pipeline.ref, pipeline: pipeline) }
- context 'when depend_on_persistent_pipeline_ref feature flag is enabled' do
- before do
- stub_feature_flags(ci_force_exposing_merge_request_refs: false)
- pipeline.persistent_ref.create
- end
-
- it 'returns the correct refspecs' do
- is_expected
- .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}")
- end
-
- context 'when ci_force_exposing_merge_request_refs feature flag is enabled' do
- before do
- stub_feature_flags(ci_force_exposing_merge_request_refs: true)
- end
-
- it 'returns the correct refspecs' do
- is_expected
- .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
- '+refs/merge-requests/1/head:refs/merge-requests/1/head')
- end
- end
-
- context 'when GIT_DEPTH is zero' do
- before do
- create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 0, pipeline: build.pipeline)
- end
-
- it 'returns the correct refspecs' do
- is_expected
- .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
- '+refs/heads/*:refs/remotes/origin/*',
- '+refs/tags/*:refs/tags/*')
- end
- end
-
- context 'when pipeline is legacy detached merge request pipeline' do
- let(:merge_request) { create(:merge_request, :with_legacy_detached_merge_request_pipeline) }
+ before do
+ pipeline.persistent_ref.create
+ end
- it 'returns the correct refspecs' do
- is_expected.to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
- "+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}")
- end
- end
+ it 'returns the correct refspecs' do
+ is_expected
+ .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}")
end
- context 'when depend_on_persistent_pipeline_ref feature flag is disabled' do
+ context 'when GIT_DEPTH is zero' do
before do
- stub_feature_flags(depend_on_persistent_pipeline_ref: false)
+ create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 0, pipeline: build.pipeline)
end
it 'returns the correct refspecs' do
is_expected
- .to contain_exactly('+refs/merge-requests/1/head:refs/merge-requests/1/head')
- end
-
- context 'when GIT_DEPTH is zero' do
- before do
- create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 0, pipeline: build.pipeline)
- end
-
- it 'returns the correct refspecs' do
- is_expected
- .to contain_exactly('+refs/merge-requests/1/head:refs/merge-requests/1/head',
- '+refs/heads/*:refs/remotes/origin/*',
- '+refs/tags/*:refs/tags/*')
- end
+ .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
+ '+refs/heads/*:refs/remotes/origin/*',
+ '+refs/tags/*:refs/tags/*')
end
+ end
- context 'when pipeline is legacy detached merge request pipeline' do
- let(:merge_request) { create(:merge_request, :with_legacy_detached_merge_request_pipeline) }
+ context 'when pipeline is legacy detached merge request pipeline' do
+ let(:merge_request) { create(:merge_request, :with_legacy_detached_merge_request_pipeline) }
- it 'returns the correct refspecs' do
- is_expected.to contain_exactly("+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}")
- end
+ it 'returns the correct refspecs' do
+ is_expected.to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
+ "+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}")
end
end
end
diff --git a/spec/presenters/clusterable_presenter_spec.rb b/spec/presenters/clusterable_presenter_spec.rb
index 47ccc59ae45..2c0a7f3e9b2 100644
--- a/spec/presenters/clusterable_presenter_spec.rb
+++ b/spec/presenters/clusterable_presenter_spec.rb
@@ -87,4 +87,20 @@ describe ClusterablePresenter do
it { is_expected.to be_nil }
end
+
+ describe '#index_path' do
+ let(:clusterable) { create(:group) }
+
+ context 'without options' do
+ subject { described_class.new(clusterable).index_path }
+
+ it { is_expected.to eq(group_clusters_path(clusterable)) }
+ end
+
+ context 'with options' do
+ subject { described_class.new(clusterable).index_path(format: :json) }
+
+ it { is_expected.to eq(group_clusters_path(clusterable, format: :json)) }
+ end
+ end
end
diff --git a/spec/presenters/pages_domain_presenter_spec.rb b/spec/presenters/pages_domain_presenter_spec.rb
index 1cae3a8c9be..30ce59b7bfb 100644
--- a/spec/presenters/pages_domain_presenter_spec.rb
+++ b/spec/presenters/pages_domain_presenter_spec.rb
@@ -45,14 +45,6 @@ describe PagesDomainPresenter do
it { is_expected.to eq(true) }
- context 'when lets_encrypt_error feature flag is disabled' do
- before do
- stub_feature_flags(pages_letsencrypt_errors: false)
- end
-
- it { is_expected.to eq(false) }
- end
-
context "when Let's Encrypt integration is disabled" do
before do
allow(::Gitlab::LetsEncrypt).to receive(:enabled?).and_return false
diff --git a/spec/presenters/projects/prometheus/alert_presenter_spec.rb b/spec/presenters/projects/prometheus/alert_presenter_spec.rb
index 85c73aa3533..967a0fb2c09 100644
--- a/spec/presenters/projects/prometheus/alert_presenter_spec.rb
+++ b/spec/presenters/projects/prometheus/alert_presenter_spec.rb
@@ -152,6 +152,148 @@ describe Projects::Prometheus::AlertPresenter do
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.starts_at}#{markdown_line_break}
+ **full_query:** `avg(metric) > 1.0`
+
+ [](#{url})
+ MARKDOWN
+ end
+
+ context 'without a starting time available' do
+ around do |example|
+ Timecop.freeze(starts_at) { example.run }
+ end
+
+ it { is_expected.to eq(expected_markdown) }
+ end
+
+ context 'with a starting time available' do
+ before do
+ payload['startsAt'] = starts_at
+ end
+
+ it { is_expected.to eq(expected_markdown) }
+ end
+ end
+
+ context 'for gitlab-managed prometheus alerts' do
+ let(:gitlab_alert) { create(:prometheus_alert, project: project) }
+ let(:metric_id) { gitlab_alert.prometheus_metric_id }
+ let(:env_id) { gitlab_alert.environment_id }
+
+ before do
+ payload['labels'] = { 'gitlab_alert_id' => metric_id }
+ end
+
+ let(:url) { "http://localhost/#{project.full_path}/prometheus/alerts/#{metric_id}/metrics_dashboard?end=2018-03-12T09%3A36%3A00Z&environment_id=#{env_id}&start=2018-03-12T08%3A36%3A00Z" }
+
+ it_behaves_like 'markdown with metrics embed'
+ end
+
+ context 'for alerts from a self-managed prometheus' do
+ let!(:environment) { create(:environment, project: project, name: 'production') }
+ let(:url) { "http://localhost/#{project.full_path}/-/environments/#{environment.id}/metrics_dashboard?embed_json=#{CGI.escape(embed_content.to_json)}&end=2018-03-12T09%3A36%3A00Z&start=2018-03-12T08%3A36%3A00Z" }
+
+ let(:title) { 'title' }
+ let(:y_label) { 'y_label' }
+ let(:query) { 'avg(metric) > 1.0' }
+ let(:embed_content) do
+ {
+ panel_groups: [{
+ panels: [{
+ type: 'line-graph',
+ title: title,
+ y_label: y_label,
+ metrics: [{ query_range: query }]
+ }]
+ }]
+ }
+ end
+
+ before do
+ # Setup embed time range
+ payload['startsAt'] = starts_at
+
+ # Setup query
+ payload['generatorURL'] = "http://host?g0.expr=#{CGI.escape(query)}"
+
+ # Setup environment
+ payload['labels'] ||= {}
+ payload['labels']['gitlab_environment_name'] = 'production'
+
+ # Setup chart title & axis labels
+ payload['annotations'] ||= {}
+ payload['annotations']['title'] = 'title'
+ payload['annotations']['gitlab_y_label'] = 'y_label'
+ end
+
+ it_behaves_like 'markdown with metrics embed'
+
+ context 'without y_label' do
+ let(:y_label) { title }
+
+ before do
+ payload['annotations'].delete('gitlab_y_label')
+ end
+
+ it_behaves_like 'markdown with metrics embed'
+ end
+
+ context 'when not enough information is present for an embed' do
+ let(:expected_markdown) do
+ <<~MARKDOWN.chomp
+ #### Summary
+
+ **Start time:** #{presenter.starts_at}#{markdown_line_break}
+ **full_query:** `avg(metric) > 1.0`
+
+ MARKDOWN
+ end
+
+ context 'without title' do
+ before do
+ payload['annotations'].delete('title')
+ end
+
+ it { is_expected.to eq(expected_markdown) }
+ end
+
+ context 'without environment' do
+ before do
+ payload['labels'].delete('gitlab_environment_name')
+ end
+
+ it { is_expected.to eq(expected_markdown) }
+ end
+
+ context 'without full_query' do
+ let(:expected_markdown) do
+ <<~MARKDOWN.chomp
+ #### Summary
+
+ **Start time:** #{presenter.starts_at}
+
+ MARKDOWN
+ end
+
+ before do
+ payload.delete('generatorURL')
+ end
+
+ it { is_expected.to eq(expected_markdown) }
+ end
+ end
+ end
+ end
end
describe '#show_performance_dashboard_link?' do
diff --git a/spec/requests/api/admin/ci/variables_spec.rb b/spec/requests/api/admin/ci/variables_spec.rb
new file mode 100644
index 00000000000..bc2f0ba50a2
--- /dev/null
+++ b/spec/requests/api/admin/ci/variables_spec.rb
@@ -0,0 +1,210 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ::API::Admin::Ci::Variables do
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:user) { create(:user) }
+
+ describe 'GET /admin/ci/variables' do
+ let!(:variable) { create(:ci_instance_variable) }
+
+ it 'returns instance-level variables for admins', :aggregate_failures do
+ get api('/admin/ci/variables', admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_a(Array)
+ end
+
+ it 'does not return instance-level variables for regular users' do
+ get api('/admin/ci/variables', user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'does not return instance-level variables for unauthorized users' do
+ get api('/admin/ci/variables')
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ describe 'GET /admin/ci/variables/:key' do
+ let!(:variable) { create(:ci_instance_variable) }
+
+ it 'returns instance-level variable details for admins', :aggregate_failures do
+ get api("/admin/ci/variables/#{variable.key}", admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['value']).to eq(variable.value)
+ expect(json_response['protected']).to eq(variable.protected?)
+ expect(json_response['variable_type']).to eq(variable.variable_type)
+ end
+
+ it 'responds with 404 Not Found if requesting non-existing variable' do
+ get api('/admin/ci/variables/non_existing_variable', admin)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'does not return instance-level variable details for regular users' do
+ get api("/admin/ci/variables/#{variable.key}", user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'does not return instance-level variable details for unauthorized users' do
+ get api("/admin/ci/variables/#{variable.key}")
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ describe 'POST /admin/ci/variables' do
+ context 'authorized user with proper permissions' do
+ let!(:variable) { create(:ci_instance_variable) }
+
+ it 'creates variable for admins', :aggregate_failures do
+ expect do
+ post api('/admin/ci/variables', admin),
+ params: {
+ key: 'TEST_VARIABLE_2',
+ value: 'PROTECTED_VALUE_2',
+ protected: true,
+ masked: true
+ }
+ end.to change { ::Ci::InstanceVariable.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['key']).to eq('TEST_VARIABLE_2')
+ expect(json_response['value']).to eq('PROTECTED_VALUE_2')
+ expect(json_response['protected']).to be_truthy
+ expect(json_response['masked']).to be_truthy
+ expect(json_response['variable_type']).to eq('env_var')
+ end
+
+ it 'creates variable with optional attributes', :aggregate_failures do
+ expect do
+ post api('/admin/ci/variables', admin),
+ params: {
+ variable_type: 'file',
+ key: 'TEST_VARIABLE_2',
+ value: 'VALUE_2'
+ }
+ end.to change { ::Ci::InstanceVariable.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['key']).to eq('TEST_VARIABLE_2')
+ expect(json_response['value']).to eq('VALUE_2')
+ expect(json_response['protected']).to be_falsey
+ expect(json_response['masked']).to be_falsey
+ expect(json_response['variable_type']).to eq('file')
+ end
+
+ it 'does not allow to duplicate variable key' do
+ expect do
+ post api('/admin/ci/variables', admin),
+ params: { key: variable.key, value: 'VALUE_2' }
+ end.not_to change { ::Ci::InstanceVariable.count }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'authorized user with invalid permissions' do
+ it 'does not create variable' do
+ post api('/admin/ci/variables', user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'does not create variable' do
+ post api('/admin/ci/variables')
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
+
+ describe 'PUT /admin/ci/variables/:key' do
+ let!(:variable) { create(:ci_instance_variable) }
+
+ context 'authorized user with proper permissions' do
+ it 'updates variable data', :aggregate_failures do
+ put api("/admin/ci/variables/#{variable.key}", admin),
+ params: {
+ variable_type: 'file',
+ value: 'VALUE_1_UP',
+ protected: true,
+ masked: true
+ }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(variable.reload.value).to eq('VALUE_1_UP')
+ expect(variable.reload).to be_protected
+ expect(json_response['variable_type']).to eq('file')
+ expect(json_response['masked']).to be_truthy
+ end
+
+ it 'responds with 404 Not Found if requesting non-existing variable' do
+ put api('/admin/ci/variables/non_existing_variable', admin)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'authorized user with invalid permissions' do
+ it 'does not update variable' do
+ put api("/admin/ci/variables/#{variable.key}", user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'does not update variable' do
+ put api("/admin/ci/variables/#{variable.key}")
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
+
+ describe 'DELETE /admin/ci/variables/:key' do
+ let!(:variable) { create(:ci_instance_variable) }
+
+ context 'authorized user with proper permissions' do
+ it 'deletes variable' do
+ expect do
+ delete api("/admin/ci/variables/#{variable.key}", admin)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end.to change { ::Ci::InstanceVariable.count }.by(-1)
+ end
+
+ it 'responds with 404 Not Found if requesting non-existing variable' do
+ delete api('/admin/ci/variables/non_existing_variable', admin)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'authorized user with invalid permissions' do
+ it 'does not delete variable' do
+ delete api("/admin/ci/variables/#{variable.key}", user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'does not delete variable' do
+ delete api("/admin/ci/variables/#{variable.key}")
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/appearance_spec.rb b/spec/requests/api/appearance_spec.rb
index 70be3adf723..f8c3db70d16 100644
--- a/spec/requests/api/appearance_spec.rb
+++ b/spec/requests/api/appearance_spec.rb
@@ -31,6 +31,7 @@ describe API::Appearance, 'Appearance' do
expect(json_response['message_background_color']).to eq('#E75E40')
expect(json_response['message_font_color']).to eq('#FFFFFF')
expect(json_response['new_project_guidelines']).to eq('')
+ expect(json_response['profile_image_guidelines']).to eq('')
expect(json_response['title']).to eq('')
end
end
@@ -51,7 +52,8 @@ describe API::Appearance, 'Appearance' do
put api("/application/appearance", admin), params: {
title: "GitLab Test Instance",
description: "gitlab-test.example.com",
- new_project_guidelines: "Please read the FAQs for help."
+ new_project_guidelines: "Please read the FAQs for help.",
+ profile_image_guidelines: "Custom profile image guidelines"
}
expect(response).to have_gitlab_http_status(:ok)
@@ -66,6 +68,7 @@ describe API::Appearance, 'Appearance' do
expect(json_response['message_background_color']).to eq('#E75E40')
expect(json_response['message_font_color']).to eq('#FFFFFF')
expect(json_response['new_project_guidelines']).to eq('Please read the FAQs for help.')
+ expect(json_response['profile_image_guidelines']).to eq('Custom profile image guidelines')
expect(json_response['title']).to eq('GitLab Test Instance')
end
end
diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb
index 97f880dd3cd..f2dc5b1c045 100644
--- a/spec/requests/api/branches_spec.rb
+++ b/spec/requests/api/branches_spec.rb
@@ -16,6 +16,7 @@ describe API::Branches do
before do
project.add_maintainer(user)
+ project.repository.add_branch(user, 'ends-with.txt', branch_sha)
end
describe "GET /projects/:id/repository/branches" do
@@ -240,6 +241,12 @@ describe API::Branches do
it_behaves_like 'repository branch'
end
+ context 'when branch contains dot txt' do
+ let(:branch_name) { project.repository.find_branch('ends-with.txt').name }
+
+ it_behaves_like 'repository branch'
+ end
+
context 'when branch contains a slash' do
let(:branch_name) { branch_with_slash.name }
@@ -623,7 +630,7 @@ describe API::Branches do
post api(route, user), params: { branch: 'new_design3', ref: 'foo' }
expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['message']).to eq('Invalid reference name: new_design3')
+ expect(json_response['message']).to eq('Invalid reference name: foo')
end
end
diff --git a/spec/requests/api/deployments_spec.rb b/spec/requests/api/deployments_spec.rb
index b820b227fff..ef2415a0cde 100644
--- a/spec/requests/api/deployments_spec.rb
+++ b/spec/requests/api/deployments_spec.rb
@@ -439,7 +439,7 @@ describe API::Deployments do
let!(:merge_request3) { create(:merge_request, source_project: project2, target_project: project2) }
it 'returns the relevant merge requests linked to a deployment for a project' do
- deployment.merge_requests << [merge_request1, merge_request2]
+ deployment.link_merge_requests(MergeRequest.where(id: [merge_request1.id, merge_request2.id]))
subject
diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb
index ce72a416c33..4ad5b4f9d49 100644
--- a/spec/requests/api/features_spec.rb
+++ b/spec/requests/api/features_spec.rb
@@ -198,7 +198,7 @@ describe API::Features do
end
end
- it 'creates a feature with the given percentage if passed an integer' do
+ it 'creates a feature with the given percentage of time if passed an integer' do
post api("/features/#{feature_name}", admin), params: { value: '50' }
expect(response).to have_gitlab_http_status(:created)
@@ -210,6 +210,19 @@ describe API::Features do
{ 'key' => 'percentage_of_time', 'value' => 50 }
])
end
+
+ it 'creates a feature with the given percentage of actors if passed an integer' do
+ post api("/features/#{feature_name}", admin), params: { value: '50', key: 'percentage_of_actors' }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'conditional',
+ 'gates' => [
+ { 'key' => 'boolean', 'value' => false },
+ { 'key' => 'percentage_of_actors', 'value' => 50 }
+ ])
+ end
end
context 'when the feature exists' do
@@ -298,7 +311,7 @@ describe API::Features do
end
end
- context 'with a pre-existing percentage value' do
+ context 'with a pre-existing percentage of time value' do
before do
feature.enable_percentage_of_time(50)
end
@@ -316,6 +329,25 @@ describe API::Features do
])
end
end
+
+ context 'with a pre-existing percentage of actors value' do
+ before do
+ feature.enable_percentage_of_actors(42)
+ end
+
+ it 'updates the percentage of actors if passed an integer' do
+ post api("/features/#{feature_name}", admin), params: { value: '74', key: 'percentage_of_actors' }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response).to eq(
+ 'name' => 'my_feature',
+ 'state' => 'conditional',
+ 'gates' => [
+ { 'key' => 'boolean', 'value' => false },
+ { 'key' => 'percentage_of_actors', 'value' => 74 }
+ ])
+ end
+ end
end
end
diff --git a/spec/requests/api/freeze_periods_spec.rb b/spec/requests/api/freeze_periods_spec.rb
new file mode 100644
index 00000000000..0b7828ebedf
--- /dev/null
+++ b/spec/requests/api/freeze_periods_spec.rb
@@ -0,0 +1,475 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe API::FreezePeriods do
+ let_it_be(:project) { create(:project, :repository, :private) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:admin) { create(:admin) }
+ let(:api_user) { user }
+ let(:invalid_cron) { '0 0 0 * *' }
+ let(:last_freeze_period) { project.freeze_periods.last }
+
+ describe 'GET /projects/:id/freeze_periods' do
+ context 'when the user is the admin' do
+ let!(:freeze_period) { create(:ci_freeze_period, project: project, created_at: 2.days.ago) }
+
+ it 'returns 200 HTTP status' do
+ get api("/projects/#{project.id}/freeze_periods", admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when the user is the maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'when there are two freeze_periods' do
+ let!(:freeze_period_1) { create(:ci_freeze_period, project: project, created_at: 2.days.ago) }
+ let!(:freeze_period_2) { create(:ci_freeze_period, project: project, created_at: 1.day.ago) }
+
+ it 'returns 200 HTTP status' do
+ get api("/projects/#{project.id}/freeze_periods", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns freeze_periods ordered by created_at ascending' do
+ get api("/projects/#{project.id}/freeze_periods", user)
+
+ expect(json_response.count).to eq(2)
+ expect(freeze_period_ids).to eq([freeze_period_1.id, freeze_period_2.id])
+ end
+
+ it 'matches response schema' do
+ get api("/projects/#{project.id}/freeze_periods", user)
+
+ expect(response).to match_response_schema('public_api/v4/freeze_periods')
+ end
+ end
+
+ context 'when there are no freeze_periods' do
+ it 'returns 200 HTTP status' do
+ get api("/projects/#{project.id}/freeze_periods", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns an empty response' do
+ get api("/projects/#{project.id}/freeze_periods", user)
+
+ expect(json_response).to be_empty
+ end
+ end
+ end
+
+ context 'when user is a guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ let!(:freeze_period) do
+ create(:ci_freeze_period, project: project)
+ end
+
+ it 'responds 403 Forbidden' do
+ get api("/projects/#{project.id}/freeze_periods", user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when user is not a project member' do
+ it 'responds 404 Not Found' do
+ get api("/projects/#{project.id}/freeze_periods", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ context 'when project is public' do
+ let(:project) { create(:project, :public) }
+
+ it 'responds 403 Forbidden' do
+ get api("/projects/#{project.id}/freeze_periods", user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+ end
+
+ describe 'GET /projects/:id/freeze_periods/:freeze_period_id' do
+ context 'when there is a freeze period' do
+ let!(:freeze_period) do
+ create(:ci_freeze_period, project: project)
+ end
+
+ context 'when the user is the admin' do
+ let!(:freeze_period) { create(:ci_freeze_period, project: project, created_at: 2.days.ago) }
+
+ it 'responds 200 OK' do
+ get api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when the user is the maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'responds 200 OK' do
+ get api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns a freeze period' do
+ get api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", user)
+
+ expect(json_response).to include(
+ 'id' => freeze_period.id,
+ 'freeze_start' => freeze_period.freeze_start,
+ 'freeze_end' => freeze_period.freeze_end,
+ 'cron_timezone' => freeze_period.cron_timezone)
+ end
+
+ it 'matches response schema' do
+ get api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", user)
+
+ expect(response).to match_response_schema('public_api/v4/freeze_period')
+ end
+ end
+
+ context 'when user is a guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'responds 403 Forbidden' do
+ get api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ context 'when project is public' do
+ let(:project) { create(:project, :public) }
+
+ context 'when freeze_period exists' do
+ it 'responds 403 Forbidden' do
+ get api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when freeze_period does not exist' do
+ it 'responds 403 Forbidden' do
+ get api("/projects/#{project.id}/freeze_periods/0", user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/freeze_periods' do
+ let(:params) do
+ {
+ freeze_start: '0 23 * * 5',
+ freeze_end: '0 7 * * 1',
+ cron_timezone: 'UTC'
+ }
+ end
+
+ subject { post api("/projects/#{project.id}/freeze_periods", api_user), params: params }
+
+ context 'when the user is the admin' do
+ let(:api_user) { admin }
+
+ it 'accepts the request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+
+ context 'when user is the maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'with valid params' do
+ it 'accepts the request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+
+ it 'creates a new freeze period' do
+ expect do
+ subject
+ end.to change { Ci::FreezePeriod.count }.by(1)
+
+ expect(last_freeze_period.freeze_start).to eq('0 23 * * 5')
+ expect(last_freeze_period.freeze_end).to eq('0 7 * * 1')
+ expect(last_freeze_period.cron_timezone).to eq('UTC')
+ end
+
+ it 'matches response schema' do
+ subject
+
+ expect(response).to match_response_schema('public_api/v4/freeze_period')
+ end
+ end
+
+ context 'with incomplete params' do
+ let(:params) do
+ {
+ freeze_start: '0 23 * * 5',
+ cron_timezone: 'UTC'
+ }
+ end
+
+ it 'responds 400 Bad Request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq("freeze_end is missing")
+ end
+ end
+
+ context 'with invalid params' do
+ let(:params) do
+ {
+ freeze_start: '0 23 * * 5',
+ freeze_end: invalid_cron,
+ cron_timezone: 'UTC'
+ }
+ end
+
+ it 'responds 400 Bad Request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']['freeze_end']).to eq([" is invalid syntax"])
+ end
+ end
+ end
+
+ context 'when user is a developer' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'responds 403 Forbidden' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when user is a reporter' do
+ before do
+ project.add_reporter(user)
+ end
+
+ it 'responds 403 Forbidden' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when user is not a project member' do
+ it 'responds 403 Forbidden' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ context 'when project is public' do
+ let(:project) { create(:project, :public) }
+
+ it 'responds 403 Forbidden' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+ end
+
+ describe 'PUT /projects/:id/freeze_periods/:freeze_period_id' do
+ let(:params) { { freeze_start: '0 22 * * 5', freeze_end: '5 4 * * sun' } }
+ let!(:freeze_period) { create :ci_freeze_period, project: project }
+
+ subject { put api("/projects/#{project.id}/freeze_periods/#{freeze_period.id}", api_user), params: params }
+
+ context 'when user is the admin' do
+ let(:api_user) { admin }
+
+ it 'accepts the request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when user is the maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'with valid params' do
+ it 'accepts the request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'performs the update' do
+ subject
+
+ freeze_period.reload
+
+ expect(freeze_period.freeze_start).to eq(params[:freeze_start])
+ expect(freeze_period.freeze_end).to eq(params[:freeze_end])
+ end
+
+ it 'matches response schema' do
+ subject
+
+ expect(response).to match_response_schema('public_api/v4/freeze_period')
+ end
+ end
+
+ context 'with invalid params' do
+ let(:params) { { freeze_start: invalid_cron } }
+
+ it 'responds 400 Bad Request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']['freeze_start']).to eq([" is invalid syntax"])
+ end
+ end
+ end
+
+ context 'when user is a reporter' do
+ before do
+ project.add_reporter(user)
+ end
+
+ it 'responds 403 Forbidden' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when user is not a project member' do
+ it 'responds 404 Not Found' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ context 'when project is public' do
+ let(:project) { create(:project, :public) }
+
+ it 'responds 403 Forbidden' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+ end
+
+ describe 'DELETE /projects/:id/freeze_periods/:freeze_period_id' do
+ let!(:freeze_period) { create :ci_freeze_period, project: project }
+ let(:freeze_period_id) { freeze_period.id }
+
+ subject { delete api("/projects/#{project.id}/freeze_periods/#{freeze_period_id}", api_user) }
+
+ context 'when user is the admin' do
+ let(:api_user) { admin }
+
+ it 'accepts the request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ context 'when user is the maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'accepts the request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+
+ it 'destroys the freeze period' do
+ expect do
+ subject
+ end.to change { Ci::FreezePeriod.count }.by(-1)
+ end
+
+ context 'when it is a non-existing freeze period id' do
+ let(:freeze_period_id) { 0 }
+
+ it '404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when user is a reporter' do
+ before do
+ project.add_reporter(user)
+ end
+
+ it 'responds 403 Forbidden' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when user is not a project member' do
+ it 'responds 404 Not Found' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ context 'when project is public' do
+ let(:project) { create(:project, :public) }
+
+ it 'responds 403 Forbidden' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+ end
+
+ def freeze_period_ids
+ json_response.map do |freeze_period_hash|
+ freeze_period_hash.fetch('id')&.to_i
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/boards/board_lists_query_spec.rb b/spec/requests/api/graphql/boards/board_lists_query_spec.rb
new file mode 100644
index 00000000000..f0927487f85
--- /dev/null
+++ b/spec/requests/api/graphql/boards/board_lists_query_spec.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'get board lists' do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:unauth_user) { create(:user) }
+ let_it_be(:project) { create(:project, creator_id: user.id, namespace: user.namespace ) }
+ let_it_be(:group) { create(:group, :private) }
+ let_it_be(:project_label) { create(:label, project: project, name: 'Development') }
+ let_it_be(:project_label2) { create(:label, project: project, name: 'Testing') }
+ let_it_be(:group_label) { create(:group_label, group: group, name: 'Development') }
+ let_it_be(:group_label2) { create(:group_label, group: group, name: 'Testing') }
+
+ let(:params) { '' }
+ let(:board) { }
+ let(:board_parent_type) { board_parent.class.to_s.downcase }
+ let(:board_data) { graphql_data[board_parent_type]['boards']['edges'].first['node'] }
+ let(:lists_data) { board_data['lists']['edges'] }
+ let(:start_cursor) { board_data['lists']['pageInfo']['startCursor'] }
+ let(:end_cursor) { board_data['lists']['pageInfo']['endCursor'] }
+
+ def query(list_params = params)
+ graphql_query_for(
+ board_parent_type,
+ { 'fullPath' => board_parent.full_path },
+ <<~BOARDS
+ boards(first: 1) {
+ edges {
+ node {
+ #{field_with_params('lists', list_params)} {
+ pageInfo {
+ startCursor
+ endCursor
+ }
+ edges {
+ node {
+ #{all_graphql_fields_for('board_lists'.classify)}
+ }
+ }
+ }
+ }
+ }
+ }
+ BOARDS
+ )
+ end
+
+ shared_examples 'group and project board lists query' do
+ let!(:board) { create(:board, resource_parent: board_parent) }
+
+ context 'when the user does not have access to the board' do
+ it 'returns nil' do
+ post_graphql(query, current_user: unauth_user)
+
+ expect(graphql_data[board_parent_type]).to be_nil
+ end
+ end
+
+ context 'when user can read the board' do
+ before do
+ board_parent.add_reporter(user)
+ end
+
+ describe 'sorting and pagination' do
+ context 'when using default sorting' do
+ let!(:label_list) { create(:list, board: board, label: label, position: 10) }
+ let!(:label_list2) { create(:list, board: board, label: label2, position: 2) }
+ let!(:backlog_list) { create(:backlog_list, board: board) }
+ let(:closed_list) { board.lists.find_by(list_type: :closed) }
+
+ before do
+ post_graphql(query, current_user: user)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ context 'when ascending' do
+ let(:lists) { [backlog_list, label_list2, label_list, closed_list] }
+ let(:expected_list_gids) do
+ lists.map { |list| list.to_global_id.to_s }
+ end
+
+ it 'sorts lists' do
+ expect(grab_ids).to eq expected_list_gids
+ end
+
+ context 'when paginating' do
+ let(:params) { 'first: 2' }
+
+ it 'sorts boards' do
+ expect(grab_ids).to eq expected_list_gids.first(2)
+
+ cursored_query = query("after: \"#{end_cursor}\"")
+ post_graphql(cursored_query, current_user: user)
+
+ response_data = grab_list_data(response.body)
+
+ expect(grab_ids(response_data)).to eq expected_list_gids.drop(2).first(2)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ describe 'for a project' do
+ let(:board_parent) { project }
+ let(:label) { project_label }
+ let(:label2) { project_label2 }
+
+ it_behaves_like 'group and project board lists query'
+ end
+
+ describe 'for a group' do
+ let(:board_parent) { group }
+ let(:label) { group_label }
+ let(:label2) { group_label2 }
+
+ before do
+ allow(board_parent).to receive(:multiple_issue_boards_available?).and_return(false)
+ end
+
+ it_behaves_like 'group and project board lists query'
+ end
+
+ def grab_ids(data = lists_data)
+ data.map { |list| list.dig('node', 'id') }
+ end
+
+ def grab_list_data(response_body)
+ Gitlab::Json.parse(response_body)['data'][board_parent_type]['boards']['edges'][0]['node']['lists']['edges']
+ end
+end
diff --git a/spec/requests/api/graphql/current_user/todos_query_spec.rb b/spec/requests/api/graphql/current_user/todos_query_spec.rb
index 82deba0d92c..321e1062a96 100644
--- a/spec/requests/api/graphql/current_user/todos_query_spec.rb
+++ b/spec/requests/api/graphql/current_user/todos_query_spec.rb
@@ -9,6 +9,7 @@ describe 'Query current user todos' do
let_it_be(:commit_todo) { create(:on_commit_todo, user: current_user, project: create(:project, :repository)) }
let_it_be(:issue_todo) { create(:todo, user: current_user, target: create(:issue)) }
let_it_be(:merge_request_todo) { create(:todo, user: current_user, target: create(:merge_request)) }
+ let_it_be(:design_todo) { create(:todo, user: current_user, target: create(:design)) }
let(:fields) do
<<~QUERY
@@ -34,7 +35,8 @@ describe 'Query current user todos' do
is_expected.to include(
a_hash_including('id' => commit_todo.to_global_id.to_s),
a_hash_including('id' => issue_todo.to_global_id.to_s),
- a_hash_including('id' => merge_request_todo.to_global_id.to_s)
+ a_hash_including('id' => merge_request_todo.to_global_id.to_s),
+ a_hash_including('id' => design_todo.to_global_id.to_s)
)
end
@@ -42,7 +44,8 @@ describe 'Query current user todos' do
is_expected.to include(
a_hash_including('targetType' => 'COMMIT'),
a_hash_including('targetType' => 'ISSUE'),
- a_hash_including('targetType' => 'MERGEREQUEST')
+ a_hash_including('targetType' => 'MERGEREQUEST'),
+ a_hash_including('targetType' => 'DESIGN')
)
end
end
diff --git a/spec/requests/api/graphql/gitlab_schema_spec.rb b/spec/requests/api/graphql/gitlab_schema_spec.rb
index cf409ea6c2d..266c98d6f08 100644
--- a/spec/requests/api/graphql/gitlab_schema_spec.rb
+++ b/spec/requests/api/graphql/gitlab_schema_spec.rb
@@ -190,7 +190,7 @@ describe 'GitlabSchema configurations' do
variables: {}.to_s,
complexity: 181,
depth: 13,
- duration: 7
+ duration_s: 7
}
expect_any_instance_of(Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer).to receive(:duration).and_return(7)
diff --git a/spec/requests/api/graphql/group/milestones_spec.rb b/spec/requests/api/graphql/group/milestones_spec.rb
index f8e3c0026f5..bad0024e7a3 100644
--- a/spec/requests/api/graphql/group/milestones_spec.rb
+++ b/spec/requests/api/graphql/group/milestones_spec.rb
@@ -7,7 +7,7 @@ describe 'Milestones through GroupQuery' do
let_it_be(:user) { create(:user) }
let_it_be(:now) { Time.now }
- let_it_be(:group) { create(:group, :private) }
+ let_it_be(:group) { create(:group) }
let_it_be(:milestone_1) { create(:milestone, group: group) }
let_it_be(:milestone_2) { create(:milestone, group: group, state: :closed, start_date: now, due_date: now + 1.day) }
let_it_be(:milestone_3) { create(:milestone, group: group, start_date: now, due_date: now + 2.days) }
@@ -17,10 +17,6 @@ describe 'Milestones through GroupQuery' do
let(:milestone_data) { graphql_data['group']['milestones']['edges'] }
describe 'Get list of milestones from a group' do
- before do
- group.add_developer(user)
- end
-
context 'when the request is correct' do
before do
fetch_milestones(user)
@@ -51,6 +47,48 @@ describe 'Milestones through GroupQuery' do
end
end
+ context 'when including milestones from decendants' do
+ let_it_be(:accessible_group) { create(:group, :private, parent: group) }
+ let_it_be(:accessible_project) { create(:project, group: accessible_group) }
+ let_it_be(:inaccessible_group) { create(:group, :private, parent: group) }
+ let_it_be(:inaccessible_project) { create(:project, :private, group: group) }
+ let_it_be(:submilestone_1) { create(:milestone, group: accessible_group) }
+ let_it_be(:submilestone_2) { create(:milestone, project: accessible_project) }
+ let_it_be(:submilestone_3) { create(:milestone, group: inaccessible_group) }
+ let_it_be(:submilestone_4) { create(:milestone, project: inaccessible_project) }
+
+ let(:args) { { include_descendants: true } }
+
+ before do
+ accessible_group.add_developer(user)
+ end
+
+ it 'returns milestones also from subgroups and subprojects visible to user' do
+ fetch_milestones(user, args)
+
+ expect_array_response(
+ milestone_1.to_global_id.to_s, milestone_2.to_global_id.to_s,
+ milestone_3.to_global_id.to_s, milestone_4.to_global_id.to_s,
+ submilestone_1.to_global_id.to_s, submilestone_2.to_global_id.to_s
+ )
+ end
+
+ context 'when group_milestone_descendants is disabled' do
+ before do
+ stub_feature_flags(group_milestone_descendants: false)
+ end
+
+ it 'ignores descendant milestones' do
+ fetch_milestones(user, args)
+
+ expect_array_response(
+ milestone_1.to_global_id.to_s, milestone_2.to_global_id.to_s,
+ milestone_3.to_global_id.to_s, milestone_4.to_global_id.to_s
+ )
+ end
+ end
+ end
+
def fetch_milestones(user = nil, args = {})
post_graphql(milestones_query(args), current_user: user)
end
diff --git a/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb b/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb
index f5a5f0a9ec2..cb35411b7a5 100644
--- a/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb
+++ b/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb
@@ -21,6 +21,7 @@ describe 'Getting Metrics Dashboard Annotations' do
create(:metrics_dashboard_annotation, environment: environment, starting_at: to.advance(minutes: 5), dashboard_path: path)
end
+ let(:args) { "from: \"#{from}\", to: \"#{to}\"" }
let(:fields) do
<<~QUERY
#{all_graphql_fields_for('MetricsDashboardAnnotation'.classify)}
@@ -47,63 +48,40 @@ describe 'Getting Metrics Dashboard Annotations' do
)
end
- context 'feature flag metrics_dashboard_annotations' do
- let(:args) { "from: \"#{from}\", to: \"#{to}\"" }
+ before do
+ project.add_developer(current_user)
+ post_graphql(query, current_user: current_user)
+ end
- before do
- project.add_developer(current_user)
- end
+ it_behaves_like 'a working graphql query'
- context 'is off' do
- before do
- stub_feature_flags(metrics_dashboard_annotations: false)
- post_graphql(query, current_user: current_user)
- end
+ it 'returns annotations' do
+ annotations = graphql_data.dig('project', 'environments', 'nodes')[0].dig('metricsDashboard', 'annotations', 'nodes')
- it 'returns empty nodes array' do
- annotations = graphql_data.dig('project', 'environments', 'nodes')[0].dig('metricsDashboard', 'annotations', 'nodes')
+ expect(annotations).to match_array [{
+ "description" => annotation.description,
+ "id" => annotation.to_global_id.to_s,
+ "panelId" => annotation.panel_xid,
+ "startingAt" => annotation.starting_at.iso8601,
+ "endingAt" => nil
+ }]
+ end
- expect(annotations).to be_empty
- end
- end
+ context 'arguments' do
+ context 'from is missing' do
+ let(:args) { "to: \"#{from}\"" }
- context 'is on' do
- before do
- stub_feature_flags(metrics_dashboard_annotations: true)
+ it 'returns error' do
post_graphql(query, current_user: current_user)
- end
- it_behaves_like 'a working graphql query'
-
- it 'returns annotations' do
- annotations = graphql_data.dig('project', 'environments', 'nodes')[0].dig('metricsDashboard', 'annotations', 'nodes')
-
- expect(annotations).to match_array [{
- "description" => annotation.description,
- "id" => annotation.to_global_id.to_s,
- "panelId" => annotation.panel_xid,
- "startingAt" => annotation.starting_at.to_s,
- "endingAt" => nil
- }]
+ expect(graphql_errors[0]).to include("message" => "Field 'annotations' is missing required arguments: from")
end
+ end
- context 'arguments' do
- context 'from is missing' do
- let(:args) { "to: \"#{from}\"" }
-
- it 'returns error' do
- post_graphql(query, current_user: current_user)
-
- expect(graphql_errors[0]).to include("message" => "Field 'annotations' is missing required arguments: from")
- end
- end
-
- context 'to is missing' do
- let(:args) { "from: \"#{from}\"" }
+ context 'to is missing' do
+ let(:args) { "from: \"#{from}\"" }
- it_behaves_like 'a working graphql query'
- end
- end
+ it_behaves_like 'a working graphql query'
end
end
end
diff --git a/spec/requests/api/graphql/mutations/alert_management/alerts/update_alert_status_spec.rb b/spec/requests/api/graphql/mutations/alert_management/alerts/update_alert_status_spec.rb
new file mode 100644
index 00000000000..fe50468134c
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/alert_management/alerts/update_alert_status_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Setting the status of an alert' do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let(:alert) { create(:alert_management_alert, project: project) }
+ let(:input) { { status: 'ACKNOWLEDGED' } }
+
+ let(:mutation) do
+ variables = {
+ project_path: project.full_path,
+ iid: alert.iid.to_s
+ }
+ graphql_mutation(:update_alert_status, variables.merge(input),
+ <<~QL
+ clientMutationId
+ errors
+ alert {
+ iid
+ status
+ }
+ QL
+ )
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:update_alert_status) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'updates the status of the alert' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['alert']['status']).to eq(input[:status])
+ 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 3fdeccc84f9..83dec7dd3e2 100644
--- a/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb
+++ b/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb
@@ -23,7 +23,7 @@ describe 'Adding an AwardEmoji' do
end
shared_examples 'a mutation that does not create an AwardEmoji' do
- it do
+ specify do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.not_to change { AwardEmoji.count }
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 c78f0c7ca27..a2997db6cae 100644
--- a/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb
+++ b/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb
@@ -24,7 +24,7 @@ describe 'Removing an AwardEmoji' do
end
shared_examples 'a mutation that does not destroy an AwardEmoji' do
- it do
+ specify do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.not_to change { AwardEmoji.count }
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 bc796b34db4..e1180c85c6b 100644
--- a/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb
+++ b/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb
@@ -23,7 +23,7 @@ describe 'Toggling an AwardEmoji' do
end
shared_examples 'a mutation that does not create or destroy an AwardEmoji' do
- it do
+ specify do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.not_to change { AwardEmoji.count }
diff --git a/spec/requests/api/graphql/mutations/branches/create_spec.rb b/spec/requests/api/graphql/mutations/branches/create_spec.rb
new file mode 100644
index 00000000000..b3c378ec2bc
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/branches/create_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Creation of a new branch' do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project, :public, :empty_repo) }
+ let(:input) { { project_path: project.full_path, name: new_branch, ref: ref } }
+ let(:new_branch) { 'new_branch' }
+ let(:ref) { 'master' }
+
+ let(:mutation) { graphql_mutation(:create_branch, input) }
+ 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']
+ end
+
+ context 'when user has permissions to create a branch' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ it 'creates a new branch' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['branch']).to include(
+ 'name' => new_branch,
+ 'commit' => a_hash_including('id')
+ )
+ end
+
+ context 'when ref is not correct' do
+ let(:new_branch) { 'another_branch' }
+ let(:ref) { 'unknown' }
+
+ it_behaves_like 'a mutation that returns errors in the response',
+ errors: ['Invalid reference name: unknown']
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/design_management/delete_spec.rb b/spec/requests/api/graphql/mutations/design_management/delete_spec.rb
new file mode 100644
index 00000000000..10376305b3e
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/design_management/delete_spec.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+describe "deleting designs" do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+
+ let(:developer) { create(:user) }
+ let(:current_user) { developer }
+ let(:issue) { create(:issue) }
+ let(:project) { issue.project }
+ let(:designs) { create_designs }
+ let(:variables) { {} }
+
+ let(:mutation) do
+ input = {
+ project_path: project.full_path,
+ iid: issue.iid,
+ filenames: designs.map(&:filename)
+ }.merge(variables)
+ graphql_mutation(:design_management_delete, input)
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:design_management_delete) }
+
+ def mutate!
+ post_graphql_mutation(mutation, current_user: current_user)
+ end
+
+ before do
+ enable_design_management
+
+ project.add_developer(developer)
+ end
+
+ shared_examples 'a failed request' do
+ let(:the_error) { be_present }
+
+ it 'reports an error' do
+ mutate!
+
+ expect(graphql_errors).to include(a_hash_including('message' => the_error))
+ end
+ end
+
+ context 'the designs list is empty' do
+ it_behaves_like 'a failed request' do
+ let(:designs) { [] }
+ let(:the_error) { a_string_matching %r/was provided invalid value/ }
+ end
+ end
+
+ context 'the designs list contains filenames we cannot find' do
+ it_behaves_like 'a failed request' do
+ let(:designs) { %w/foo bar baz/.map { |fn| OpenStruct.new(filename: fn) } }
+ let(:the_error) { a_string_matching %r/filenames were not found/ }
+ end
+ end
+
+ context 'the current user does not have developer access' do
+ it_behaves_like 'a failed request' do
+ let(:current_user) { create(:user) }
+ let(:the_error) { a_string_matching %r/you don't have permission/ }
+ end
+ end
+
+ context "when the issue does not exist" do
+ it_behaves_like 'a failed request' do
+ let(:variables) { { iid: "1234567890" } }
+ let(:the_error) { a_string_matching %r/does not exist/ }
+ end
+ end
+
+ context "when saving the designs raises an error" do
+ let(:designs) { create_designs(1) }
+
+ it "responds with errors" do
+ expect_next_instance_of(::DesignManagement::DeleteDesignsService) do |service|
+ expect(service)
+ .to receive(:execute)
+ .and_return({ status: :error, message: "Something went wrong" })
+ end
+
+ mutate!
+
+ expect(mutation_response).to include('errors' => include(eq "Something went wrong"))
+ end
+ end
+
+ context 'one of the designs is already deleted' do
+ let(:designs) do
+ create_designs(2).push(create(:design, :with_file, deleted: true, issue: issue))
+ end
+
+ it 'reports an error' do
+ mutate!
+
+ expect(graphql_errors).to be_present
+ end
+ end
+
+ context 'when the user names designs to delete' do
+ before do
+ create_designs(1)
+ end
+
+ let!(:designs) { create_designs(2) }
+
+ it 'deletes the designs' do
+ expect { mutate! }
+ .to change { issue.reset.designs.current.count }.from(3).to(1)
+ end
+
+ it 'has no errors' do
+ mutate!
+
+ expect(mutation_response).to include('errors' => be_empty)
+ end
+ end
+
+ private
+
+ def create_designs(how_many = 2)
+ create_list(:design, how_many, :with_file, issue: issue)
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/design_management/upload_spec.rb b/spec/requests/api/graphql/mutations/design_management/upload_spec.rb
new file mode 100644
index 00000000000..22adc064406
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/design_management/upload_spec.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+require "spec_helper"
+
+describe "uploading designs" do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+ include WorkhorseHelpers
+
+ let(:current_user) { create(:user) }
+ let(:issue) { create(:issue) }
+ let(:project) { issue.project }
+ let(:files) { [fixture_file_upload("spec/fixtures/dk.png")] }
+ let(:variables) { {} }
+
+ let(:mutation) do
+ input = {
+ project_path: project.full_path,
+ iid: issue.iid,
+ files: files
+ }.merge(variables)
+ graphql_mutation(:design_management_upload, input)
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:design_management_upload) }
+
+ before do
+ enable_design_management
+
+ project.add_developer(current_user)
+ end
+
+ it "returns an error if the user is not allowed to upload designs" do
+ post_graphql_mutation(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)
+
+ 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(
+ a_hash_including("filename" => "dk.png")
+ )
+ )
+ end
+
+ it "can respond with skipped designs" do
+ 2.times do
+ post_graphql_mutation(mutation, current_user: current_user)
+ files.each(&:rewind)
+ end
+
+ expect(mutation_response).to include(
+ "skippedDesigns" => a_collection_containing_exactly(
+ a_hash_including("filename" => "dk.png")
+ )
+ )
+ end
+
+ context "when the issue does not exist" do
+ let(:variables) { { iid: "123" } }
+
+ it "returns an error" do
+ post_graphql_mutation(mutation, current_user: create(:user))
+
+ expect(graphql_errors).not_to be_empty
+ end
+ end
+
+ context "when saving the designs raises an error" do
+ it "responds with errors" do
+ expect_next_instance_of(::DesignManagement::SaveDesignsService) do |service|
+ expect(service).to receive(:execute).and_return({ status: :error, message: "Something went wrong" })
+ end
+
+ post_graphql_mutation(mutation, current_user: current_user)
+ expect(mutation_response["errors"].first).to eq("Something went wrong")
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/jira_import/start_spec.rb b/spec/requests/api/graphql/mutations/jira_import/start_spec.rb
index 014da5d1e1a..84110098400 100644
--- a/spec/requests/api/graphql/mutations/jira_import/start_spec.rb
+++ b/spec/requests/api/graphql/mutations/jira_import/start_spec.rb
@@ -3,6 +3,7 @@
require 'spec_helper'
describe 'Starting a Jira Import' do
+ include JiraServiceHelper
include GraphqlHelpers
let_it_be(:user) { create(:user) }
@@ -104,6 +105,8 @@ describe 'Starting a Jira Import' do
before do
project.reload
+
+ stub_jira_service_test
end
context 'when issues feature are disabled' do
@@ -118,7 +121,7 @@ describe 'Starting a Jira Import' do
it_behaves_like 'a mutation that returns errors in the response', errors: ['Unable to find Jira project to import data from.']
end
- context 'when jira import successfully scheduled' do
+ context 'when Jira import successfully scheduled' do
it 'schedules a Jira import' do
post_graphql_mutation(mutation, current_user: current_user)
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
new file mode 100644
index 00000000000..8568dc8ffc0
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb
@@ -0,0 +1,231 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Mutations::Metrics::Dashboard::Annotations::Create do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project, :private, :repository) }
+ let_it_be(:environment) { create(:environment, project: project) }
+ let_it_be(:cluster) { create(:cluster, projects: [project]) }
+ let(:dashboard_path) { 'config/prometheus/common_metrics.yml' }
+ let(:starting_at) { Time.current.iso8601 }
+ let(:ending_at) { 1.hour.from_now.iso8601 }
+ let(:description) { 'test description' }
+
+ def mutation_response
+ graphql_mutation_response(:create_annotation)
+ end
+
+ specify { expect(described_class).to require_graphql_authorizations(:create_metrics_dashboard_annotation) }
+
+ context 'when annotation source is environment' do
+ let(:mutation) do
+ variables = {
+ environment_id: GitlabSchema.id_from_object(environment).to_s,
+ starting_at: starting_at,
+ ending_at: ending_at,
+ dashboard_path: dashboard_path,
+ description: description
+ }
+
+ graphql_mutation(:create_annotation, variables)
+ end
+
+ context 'when the user does not have permission' do
+ before do
+ project.add_reporter(current_user)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors',
+ errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
+
+ it 'does not create the annotation' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end.not_to change { Metrics::Dashboard::Annotation.count }
+ end
+ end
+
+ context 'when the user has permission' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ it 'creates the annotation' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end.to change { Metrics::Dashboard::Annotation.count }.by(1)
+ end
+
+ it 'returns the created annotation' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ annotation = Metrics::Dashboard::Annotation.first
+ annotation_id = GitlabSchema.id_from_object(annotation).to_s
+
+ expect(mutation_response['annotation']['description']).to match(description)
+ expect(mutation_response['annotation']['startingAt'].to_time).to match(starting_at.to_time)
+ expect(mutation_response['annotation']['endingAt'].to_time).to match(ending_at.to_time)
+ expect(mutation_response['annotation']['id']).to match(annotation_id)
+ expect(annotation.environment_id).to eq(environment.id)
+ end
+
+ context 'when environment_id is missing' do
+ let(:mutation) do
+ variables = {
+ environment_id: nil,
+ starting_at: starting_at,
+ ending_at: ending_at,
+ dashboard_path: dashboard_path,
+ description: description
+ }
+
+ graphql_mutation(:create_annotation, variables)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors', errors: [described_class::ANNOTATION_SOURCE_ARGUMENT_ERROR]
+ end
+
+ context 'when environment_id is invalid' do
+ let(:mutation) do
+ variables = {
+ environment_id: 'invalid_id',
+ starting_at: starting_at,
+ ending_at: ending_at,
+ dashboard_path: dashboard_path,
+ description: description
+ }
+
+ 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.']
+ end
+ end
+ end
+
+ context 'when annotation source is cluster' do
+ let(:mutation) do
+ variables = {
+ cluster_id: GitlabSchema.id_from_object(cluster).to_s,
+ starting_at: starting_at,
+ ending_at: ending_at,
+ dashboard_path: dashboard_path,
+ description: description
+ }
+
+ graphql_mutation(:create_annotation, variables)
+ end
+
+ context 'with permission' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ it 'creates the annotation' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end.to change { Metrics::Dashboard::Annotation.count }.by(1)
+ end
+
+ it 'returns the created annotation' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ annotation = Metrics::Dashboard::Annotation.first
+ annotation_id = GitlabSchema.id_from_object(annotation).to_s
+
+ expect(mutation_response['annotation']['description']).to match(description)
+ expect(mutation_response['annotation']['startingAt'].to_time).to match(starting_at.to_time)
+ expect(mutation_response['annotation']['endingAt'].to_time).to match(ending_at.to_time)
+ expect(mutation_response['annotation']['id']).to match(annotation_id)
+ expect(annotation.cluster_id).to eq(cluster.id)
+ end
+
+ context 'when cluster_id is missing' do
+ let(:mutation) do
+ variables = {
+ cluster_id: nil,
+ starting_at: starting_at,
+ ending_at: ending_at,
+ dashboard_path: dashboard_path,
+ description: description
+ }
+
+ graphql_mutation(:create_annotation, variables)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors', errors: [described_class::ANNOTATION_SOURCE_ARGUMENT_ERROR]
+ end
+ end
+
+ context 'without permission' do
+ before do
+ project.add_guest(current_user)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors',
+ errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
+
+ it 'does not create the annotation' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end.not_to change { Metrics::Dashboard::Annotation.count }
+ end
+ end
+
+ context 'when cluster_id is invalid' do
+ let(:mutation) do
+ variables = {
+ cluster_id: 'invalid_id',
+ starting_at: starting_at,
+ ending_at: ending_at,
+ dashboard_path: dashboard_path,
+ description: description
+ }
+
+ 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.']
+ end
+ end
+
+ context 'when both environment_id and cluster_id are provided' do
+ let(:mutation) do
+ variables = {
+ environment_id: GitlabSchema.id_from_object(environment).to_s,
+ cluster_id: GitlabSchema.id_from_object(cluster).to_s,
+ starting_at: starting_at,
+ ending_at: ending_at,
+ dashboard_path: dashboard_path,
+ description: description
+ }
+
+ graphql_mutation(:create_annotation, variables)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors', errors: [described_class::ANNOTATION_SOURCE_ARGUMENT_ERROR]
+ end
+
+ context 'when a non-cluster or environment id is provided' do
+ let(:mutation) do
+ variables = {
+ environment_id: GitlabSchema.id_from_object(project).to_s,
+ starting_at: starting_at,
+ ending_at: ending_at,
+ dashboard_path: dashboard_path,
+ description: description
+ }
+
+ graphql_mutation(:create_annotation, variables)
+ end
+
+ before do
+ project.add_developer(current_user)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors', errors: [described_class::INVALID_ANNOTATION_SOURCE_ERROR]
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/snippets/create_spec.rb b/spec/requests/api/graphql/mutations/snippets/create_spec.rb
index cef7fc5cbe3..e1e5fe22887 100644
--- a/spec/requests/api/graphql/mutations/snippets/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/create_spec.rb
@@ -13,6 +13,7 @@ describe 'Creating a Snippet' do
let(:file_name) { 'Initial file_name' }
let(:visibility_level) { 'public' }
let(:project_path) { nil }
+ let(:uploaded_files) { nil }
let(:mutation) do
variables = {
@@ -21,7 +22,8 @@ describe 'Creating a Snippet' do
visibility_level: visibility_level,
file_name: file_name,
title: title,
- project_path: project_path
+ project_path: project_path,
+ uploaded_files: uploaded_files
}
graphql_mutation(:create_snippet, variables)
@@ -31,6 +33,8 @@ describe 'Creating a Snippet' do
graphql_mutation_response(:create_snippet)
end
+ subject { post_graphql_mutation(mutation, current_user: current_user) }
+
context 'when the user does not have permission' do
let(:current_user) { nil }
@@ -39,7 +43,7 @@ describe 'Creating a Snippet' do
it 'does not create the Snippet' do
expect do
- post_graphql_mutation(mutation, current_user: current_user)
+ subject
end.not_to change { Snippet.count }
end
@@ -48,7 +52,7 @@ describe 'Creating a Snippet' do
it 'does not create the snippet when the user is not authorized' do
expect do
- post_graphql_mutation(mutation, current_user: current_user)
+ subject
end.not_to change { Snippet.count }
end
end
@@ -60,12 +64,12 @@ describe 'Creating a Snippet' do
context 'with PersonalSnippet' do
it 'creates the Snippet' do
expect do
- post_graphql_mutation(mutation, current_user: current_user)
+ subject
end.to change { Snippet.count }.by(1)
end
it 'returns the created Snippet' do
- post_graphql_mutation(mutation, current_user: current_user)
+ subject
expect(mutation_response['snippet']['blob']['richData']).to be_nil
expect(mutation_response['snippet']['blob']['plainData']).to match(content)
@@ -86,12 +90,12 @@ describe 'Creating a Snippet' do
it 'creates the Snippet' do
expect do
- post_graphql_mutation(mutation, current_user: current_user)
+ subject
end.to change { Snippet.count }.by(1)
end
it 'returns the created Snippet' do
- post_graphql_mutation(mutation, current_user: current_user)
+ subject
expect(mutation_response['snippet']['blob']['richData']).to be_nil
expect(mutation_response['snippet']['blob']['plainData']).to match(content)
@@ -106,7 +110,7 @@ describe 'Creating a Snippet' do
let(:project_path) { 'foobar' }
it 'returns an an error' do
- post_graphql_mutation(mutation, current_user: current_user)
+ subject
errors = json_response['errors']
expect(errors.first['message']).to eq(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR)
@@ -117,7 +121,7 @@ describe 'Creating a Snippet' do
it 'returns an an error' do
project.project_feature.update_attribute(:snippets_access_level, ProjectFeature::DISABLED)
- post_graphql_mutation(mutation, current_user: current_user)
+ subject
errors = json_response['errors']
expect(errors.first['message']).to eq(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR)
@@ -132,15 +136,41 @@ describe 'Creating a Snippet' do
it 'does not create the Snippet' do
expect do
- post_graphql_mutation(mutation, current_user: current_user)
+ subject
end.not_to change { Snippet.count }
end
it 'does not return Snippet' do
- post_graphql_mutation(mutation, current_user: current_user)
+ subject
expect(mutation_response['snippet']).to be_nil
end
end
+
+ context 'when there uploaded files' do
+ shared_examples 'expected files argument' do |file_value, expected_value|
+ let(:uploaded_files) { file_value }
+
+ it do
+ expect(::Snippets::CreateService).to receive(:new).with(nil, user, hash_including(files: expected_value))
+
+ subject
+ end
+ end
+
+ it_behaves_like 'expected files argument', nil, nil
+ it_behaves_like 'expected files argument', %w(foo bar), %w(foo bar)
+ it_behaves_like 'expected files argument', 'foo', %w(foo)
+
+ context 'when files has an invalid value' do
+ let(:uploaded_files) { [1] }
+
+ it 'returns an error' do
+ subject
+
+ expect(json_response['errors']).to be
+ end
+ end
+ end
end
end
diff --git a/spec/requests/api/graphql/mutations/snippets/destroy_spec.rb b/spec/requests/api/graphql/mutations/snippets/destroy_spec.rb
index 351d2db8973..cb9aeea74b2 100644
--- a/spec/requests/api/graphql/mutations/snippets/destroy_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/destroy_spec.rb
@@ -6,9 +6,10 @@ describe 'Destroying a Snippet' do
include GraphqlHelpers
let(:current_user) { snippet.author }
+ let(:snippet_gid) { snippet.to_global_id.to_s }
let(:mutation) do
variables = {
- id: snippet.to_global_id.to_s
+ id: snippet_gid
}
graphql_mutation(:destroy_snippet, variables)
@@ -49,9 +50,11 @@ describe 'Destroying a Snippet' do
end
describe 'PersonalSnippet' do
- it_behaves_like 'graphql delete actions' do
- let_it_be(:snippet) { create(:personal_snippet) }
- end
+ let_it_be(:snippet) { create(:personal_snippet) }
+
+ it_behaves_like 'graphql delete actions'
+
+ it_behaves_like 'when the snippet is not found'
end
describe 'ProjectSnippet' do
@@ -85,5 +88,7 @@ describe 'Destroying a Snippet' do
end
end
end
+
+ it_behaves_like 'when the snippet is not found'
end
end
diff --git a/spec/requests/api/graphql/mutations/snippets/mark_as_spam_spec.rb b/spec/requests/api/graphql/mutations/snippets/mark_as_spam_spec.rb
index 05e3f7e6806..6d4dce3f6f1 100644
--- a/spec/requests/api/graphql/mutations/snippets/mark_as_spam_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/mark_as_spam_spec.rb
@@ -10,9 +10,11 @@ describe 'Mark snippet as spam', :do_not_mock_admin_mode do
let_it_be(:snippet) { create(:personal_snippet) }
let_it_be(:user_agent_detail) { create(:user_agent_detail, subject: snippet) }
let(:current_user) { snippet.author }
+
+ let(:snippet_gid) { snippet.to_global_id.to_s }
let(:mutation) do
variables = {
- id: snippet.to_global_id.to_s
+ id: snippet_gid
}
graphql_mutation(:mark_as_spam_snippet, variables)
@@ -23,13 +25,15 @@ describe 'Mark snippet as spam', :do_not_mock_admin_mode do
end
shared_examples 'does not mark the snippet as spam' do
- it do
+ specify do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.not_to change { snippet.reload.user_agent_detail.submitted }
end
end
+ it_behaves_like 'when the snippet is not found'
+
context 'when the user does not have permission' do
let(:current_user) { other_user }
diff --git a/spec/requests/api/graphql/mutations/snippets/update_spec.rb b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
index 1035e3346e1..968ea5aed52 100644
--- a/spec/requests/api/graphql/mutations/snippets/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
@@ -15,9 +15,10 @@ describe 'Updating a Snippet' do
let(:updated_file_name) { 'Updated file_name' }
let(:current_user) { snippet.author }
+ let(:snippet_gid) { GitlabSchema.id_from_object(snippet).to_s }
let(:mutation) do
variables = {
- id: GitlabSchema.id_from_object(snippet).to_s,
+ id: snippet_gid,
content: updated_content,
description: updated_description,
visibility_level: 'public',
@@ -90,16 +91,18 @@ describe 'Updating a Snippet' do
end
describe 'PersonalSnippet' do
- it_behaves_like 'graphql update actions' do
- let(:snippet) do
- create(:personal_snippet,
- :private,
- file_name: original_file_name,
- title: original_title,
- content: original_content,
- description: original_description)
- end
+ let(:snippet) do
+ create(:personal_snippet,
+ :private,
+ file_name: original_file_name,
+ title: original_title,
+ content: original_content,
+ description: original_description)
end
+
+ it_behaves_like 'graphql update actions'
+
+ it_behaves_like 'when the snippet is not found'
end
describe 'ProjectSnippet' do
@@ -142,5 +145,7 @@ describe 'Updating a Snippet' do
end
end
end
+
+ it_behaves_like 'when the snippet is not found'
end
end
diff --git a/spec/requests/api/graphql/project/alert_management/alert_status_counts_spec.rb b/spec/requests/api/graphql/project/alert_management/alert_status_counts_spec.rb
new file mode 100644
index 00000000000..ffd328429ef
--- /dev/null
+++ b/spec/requests/api/graphql/project/alert_management/alert_status_counts_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe 'getting Alert Management Alert counts by status' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:alert_1) { create(:alert_management_alert, :resolved, project: project) }
+ let_it_be(:alert_2) { create(:alert_management_alert, project: project) }
+ let_it_be(:other_project_alert) { create(:alert_management_alert) }
+ let(:params) { {} }
+
+ let(:fields) do
+ <<~QUERY
+ #{all_graphql_fields_for('AlertManagementAlertStatusCountsType'.classify)}
+ QUERY
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'project',
+ { 'fullPath' => project.full_path },
+ query_graphql_field('alertManagementAlertStatusCounts', params, fields)
+ )
+ end
+
+ context 'with alert data' do
+ let(:alert_counts) { graphql_data.dig('project', 'alertManagementAlertStatusCounts') }
+
+ context 'without project permissions' do
+ let(:user) { create(:user) }
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it_behaves_like 'a working graphql query'
+ it { expect(alert_counts).to be nil }
+ end
+
+ context 'with project permissions' do
+ before do
+ project.add_developer(current_user)
+ post_graphql(query, current_user: current_user)
+ end
+
+ it_behaves_like 'a working graphql query'
+ it 'returns the correct counts for each status' do
+ expect(alert_counts).to eq(
+ 'open' => 1,
+ 'all' => 2,
+ 'triggered' => 1,
+ 'acknowledged' => 0,
+ 'resolved' => 1,
+ 'ignored' => 0
+ )
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/alert_management/alerts_spec.rb b/spec/requests/api/graphql/project/alert_management/alerts_spec.rb
new file mode 100644
index 00000000000..c226e659364
--- /dev/null
+++ b/spec/requests/api/graphql/project/alert_management/alerts_spec.rb
@@ -0,0 +1,139 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe 'getting Alert Management Alerts' do
+ include GraphqlHelpers
+
+ let_it_be(:payload) { { 'custom' => { 'alert' => 'payload' } } }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:resolved_alert) { create(:alert_management_alert, :all_fields, :resolved, project: project, issue: nil, severity: :low) }
+ let_it_be(:triggered_alert) { create(:alert_management_alert, :all_fields, project: project, severity: :critical, payload: payload) }
+ let_it_be(:other_project_alert) { create(:alert_management_alert, :all_fields) }
+ let(:params) { {} }
+
+ let(:fields) do
+ <<~QUERY
+ nodes {
+ #{all_graphql_fields_for('AlertManagementAlert'.classify)}
+ }
+ QUERY
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'project',
+ { 'fullPath' => project.full_path },
+ query_graphql_field('alertManagementAlerts', params, fields)
+ )
+ end
+
+ context 'with alert data' do
+ let(:alerts) { graphql_data.dig('project', 'alertManagementAlerts', 'nodes') }
+
+ context 'without project permissions' do
+ let(:user) { create(:user) }
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it { expect(alerts).to be nil }
+ end
+
+ context 'with project permissions' do
+ before do
+ project.add_developer(current_user)
+ post_graphql(query, current_user: current_user)
+ end
+
+ let(:first_alert) { alerts.first }
+ let(:second_alert) { alerts.second }
+
+ it_behaves_like 'a working graphql query'
+
+ it { expect(alerts.size).to eq(2) }
+
+ it 'returns the correct properties of the alerts' do
+ expect(first_alert).to include(
+ 'iid' => triggered_alert.iid.to_s,
+ 'issueIid' => triggered_alert.issue_iid.to_s,
+ 'title' => triggered_alert.title,
+ 'description' => triggered_alert.description,
+ 'severity' => triggered_alert.severity.upcase,
+ 'status' => 'TRIGGERED',
+ 'monitoringTool' => triggered_alert.monitoring_tool,
+ 'service' => triggered_alert.service,
+ 'hosts' => triggered_alert.hosts,
+ 'eventCount' => triggered_alert.events,
+ 'startedAt' => triggered_alert.started_at.strftime('%Y-%m-%dT%H:%M:%SZ'),
+ 'endedAt' => nil,
+ 'details' => { 'custom.alert' => 'payload' },
+ 'createdAt' => triggered_alert.created_at.strftime('%Y-%m-%dT%H:%M:%SZ'),
+ 'updatedAt' => triggered_alert.updated_at.strftime('%Y-%m-%dT%H:%M:%SZ')
+ )
+
+ expect(second_alert).to include(
+ 'iid' => resolved_alert.iid.to_s,
+ 'issueIid' => nil,
+ 'status' => 'RESOLVED',
+ 'endedAt' => resolved_alert.ended_at.strftime('%Y-%m-%dT%H:%M:%SZ')
+ )
+ end
+
+ context 'with iid given' do
+ let(:params) { { iid: resolved_alert.iid.to_s } }
+
+ it_behaves_like 'a working graphql query'
+
+ it { expect(alerts.size).to eq(1) }
+ it { expect(first_alert['iid']).to eq(resolved_alert.iid.to_s) }
+ end
+
+ context 'with statuses given' do
+ let(:params) { 'statuses: [TRIGGERED, ACKNOWLEDGED]' }
+
+ it_behaves_like 'a working graphql query'
+
+ it { expect(alerts.size).to eq(1) }
+ it { expect(first_alert['iid']).to eq(triggered_alert.iid.to_s) }
+ end
+
+ context 'sorting data given' do
+ let(:params) { 'sort: SEVERITY_DESC' }
+ let(:iids) { alerts.map { |a| a['iid'] } }
+
+ it_behaves_like 'a working graphql query'
+
+ it 'sorts in the correct order' do
+ expect(iids).to eq [resolved_alert.iid.to_s, triggered_alert.iid.to_s]
+ end
+
+ context 'ascending order' do
+ let(:params) { 'sort: SEVERITY_ASC' }
+
+ it 'sorts in the correct order' do
+ expect(iids).to eq [triggered_alert.iid.to_s, resolved_alert.iid.to_s]
+ end
+ end
+ end
+
+ context 'searching' do
+ let(:params) { { search: resolved_alert.title } }
+
+ it_behaves_like 'a working graphql query'
+
+ it { expect(alerts.size).to eq(1) }
+ it { expect(first_alert['iid']).to eq(resolved_alert.iid.to_s) }
+
+ context 'unknown criteria' do
+ let(:params) { { search: 'something random' } }
+
+ it { expect(alerts.size).to eq(0) }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/grafana_integration_spec.rb b/spec/requests/api/graphql/project/grafana_integration_spec.rb
index e7155934b3a..c9bc6c1a68e 100644
--- a/spec/requests/api/graphql/project/grafana_integration_spec.rb
+++ b/spec/requests/api/graphql/project/grafana_integration_spec.rb
@@ -35,7 +35,7 @@ describe 'Getting Grafana Integration' do
it_behaves_like 'a working graphql query'
- it { expect(integration_data).to be nil }
+ specify { expect(integration_data).to be nil }
end
context 'with project admin permissions' do
@@ -45,16 +45,16 @@ describe 'Getting Grafana Integration' do
it_behaves_like 'a working graphql query'
- it { expect(integration_data['token']).to eql grafana_integration.masked_token }
- it { expect(integration_data['grafanaUrl']).to eql grafana_integration.grafana_url }
+ specify { expect(integration_data['token']).to eql grafana_integration.masked_token }
+ specify { expect(integration_data['grafanaUrl']).to eql grafana_integration.grafana_url }
- it do
+ specify do
expect(
integration_data['createdAt']
).to eql grafana_integration.created_at.strftime('%Y-%m-%dT%H:%M:%SZ')
end
- it do
+ specify do
expect(
integration_data['updatedAt']
).to eql grafana_integration.updated_at.strftime('%Y-%m-%dT%H:%M:%SZ')
diff --git a/spec/requests/api/graphql/project/issue/design_collection/version_spec.rb b/spec/requests/api/graphql/project/issue/design_collection/version_spec.rb
new file mode 100644
index 00000000000..04f445b4318
--- /dev/null
+++ b/spec/requests/api/graphql/project/issue/design_collection/version_spec.rb
@@ -0,0 +1,216 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Query.project(fullPath).issue(iid).designCollection.version(sha)' do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:stranger) { create(:user) }
+ let_it_be(:old_version) do
+ create(:design_version, issue: issue,
+ created_designs: create_list(:design, 3, issue: issue))
+ end
+ let_it_be(:version) do
+ create(:design_version, issue: issue,
+ modified_designs: old_version.designs,
+ created_designs: create_list(:design, 2, issue: issue))
+ end
+
+ let(:current_user) { developer }
+
+ def query(vq = version_fields)
+ graphql_query_for(:project, { fullPath: project.full_path },
+ query_graphql_field(:issue, { iid: issue.iid.to_s },
+ query_graphql_field(:design_collection, nil,
+ query_graphql_field(:version, { sha: version.sha }, vq))))
+ end
+
+ let(:post_query) { post_graphql(query, current_user: current_user) }
+ let(:path_prefix) { %w[project issue designCollection version] }
+
+ let(:data) { graphql_data.dig(*path) }
+
+ before do
+ enable_design_management
+ project.add_developer(developer)
+ end
+
+ describe 'scalar fields' do
+ let(:path) { path_prefix }
+ let(:version_fields) { query_graphql_field(:sha) }
+
+ before do
+ post_query
+ end
+
+ { id: ->(x) { x.to_global_id.to_s }, sha: ->(x) { x.sha } }.each do |field, value|
+ describe ".#{field}" do
+ let(:version_fields) { query_graphql_field(field) }
+
+ it "retrieves the #{field}" do
+ expect(data).to match(a_hash_including(field.to_s => value[version]))
+ end
+ end
+ end
+ end
+
+ describe 'design_at_version' do
+ let(:path) { path_prefix + %w[designAtVersion] }
+ let(:design) { issue.designs.visible_at_version(version).to_a.sample }
+ let(:design_at_version) { build(:design_at_version, design: design, version: version) }
+
+ let(:version_fields) do
+ query_graphql_field(:design_at_version, dav_params, 'id filename')
+ end
+
+ shared_examples :finds_dav do
+ it 'finds all the designs as of the given version' do
+ post_query
+
+ expect(data).to match(
+ a_hash_including(
+ 'id' => global_id_of(design_at_version),
+ 'filename' => design.filename
+ ))
+ end
+
+ context 'when the current_user is not authorized' do
+ let(:current_user) { stranger }
+
+ it 'returns nil' do
+ post_query
+
+ expect(data).to be_nil
+ end
+ end
+ end
+
+ context 'by ID' do
+ let(:dav_params) { { id: global_id_of(design_at_version) } }
+
+ include_examples :finds_dav
+ end
+
+ context 'by filename' do
+ let(:dav_params) { { filename: design.filename } }
+
+ include_examples :finds_dav
+ end
+
+ context 'by design_id' do
+ let(:dav_params) { { design_id: global_id_of(design) } }
+
+ include_examples :finds_dav
+ end
+ end
+
+ describe 'designs_at_version' do
+ let(:path) { path_prefix + %w[designsAtVersion edges] }
+ let(:version_fields) do
+ query_graphql_field(:designs_at_version, dav_params, 'edges { node { id filename } }')
+ end
+
+ let(:dav_params) { nil }
+
+ let(:results) do
+ issue.designs.visible_at_version(version).map do |d|
+ dav = build(:design_at_version, design: d, version: version)
+ { 'id' => global_id_of(dav), 'filename' => d.filename }
+ end
+ end
+
+ it 'finds all the designs as of the given version' do
+ post_query
+
+ expect(data.pluck('node')).to match_array(results)
+ end
+
+ describe 'filtering' do
+ let(:designs) { issue.designs.sample(3) }
+ let(:filenames) { designs.map(&:filename) }
+ let(:ids) do
+ designs.map { |d| global_id_of(build(:design_at_version, design: d, version: version)) }
+ end
+
+ before do
+ post_query
+ end
+
+ describe 'by filename' do
+ let(:dav_params) { { filenames: filenames } }
+
+ it 'finds the designs by filename' do
+ expect(data.map { |e| e.dig('node', 'id') }).to match_array(ids)
+ end
+ end
+
+ describe 'by design-id' do
+ let(:dav_params) { { ids: designs.map { |d| global_id_of(d) } } }
+
+ it 'finds the designs by id' do
+ expect(data.map { |e| e.dig('node', 'filename') }).to match_array(filenames)
+ end
+ end
+ end
+
+ describe 'pagination' do
+ let(:end_cursor) { graphql_data_at(*path_prefix, :designs_at_version, :page_info, :end_cursor) }
+
+ let(:ids) do
+ ::DesignManagement::Design.visible_at_version(version).order(:id).map do |d|
+ global_id_of(build(:design_at_version, design: d, version: version))
+ end
+ end
+
+ let(:version_fields) do
+ query_graphql_field(:designs_at_version, { first: 2 }, fields)
+ end
+
+ let(:cursored_query) do
+ frag = query_graphql_field(:designs_at_version, { after: end_cursor }, fields)
+ query(frag)
+ end
+
+ let(:fields) { ['pageInfo { endCursor }', 'edges { node { id } }'] }
+
+ def response_values(data = graphql_data)
+ data.dig(*path).map { |e| e.dig('node', 'id') }
+ end
+
+ it 'sorts designs for reliable pagination' do
+ post_graphql(query, current_user: current_user)
+
+ expect(response_values).to match_array(ids.take(2))
+
+ post_graphql(cursored_query, current_user: current_user)
+
+ new_data = Gitlab::Json.parse(response.body).fetch('data')
+
+ expect(response_values(new_data)).to match_array(ids.drop(2))
+ end
+ end
+ end
+
+ describe 'designs' do
+ let(:path) { path_prefix + %w[designs edges] }
+ let(:version_fields) do
+ query_graphql_field(:designs, nil, 'edges { node { id filename } }')
+ end
+
+ let(:results) do
+ version.designs.map do |design|
+ { 'id' => global_id_of(design), 'filename' => design.filename }
+ end
+ end
+
+ it 'finds all the designs as of the given version' do
+ post_query
+
+ expect(data.pluck('node')).to match_array(results)
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/issue/design_collection/versions_spec.rb b/spec/requests/api/graphql/project/issue/design_collection/versions_spec.rb
new file mode 100644
index 00000000000..18787bf925d
--- /dev/null
+++ b/spec/requests/api/graphql/project/issue/design_collection/versions_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Getting versions related to an issue' do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+
+ let_it_be(:issue) { create(:issue) }
+
+ let_it_be(:version_a) do
+ create(:design_version, issue: issue)
+ end
+ let_it_be(:version_b) do
+ create(:design_version, issue: issue)
+ end
+ let_it_be(:version_c) do
+ create(:design_version, issue: issue)
+ end
+ let_it_be(:version_d) do
+ create(:design_version, issue: issue)
+ end
+
+ let_it_be(:owner) { issue.project.owner }
+
+ def version_query(params = version_params)
+ query_graphql_field(:versions, params, version_query_fields)
+ end
+
+ let(:version_params) { nil }
+
+ let(:version_query_fields) { ['edges { node { sha } }'] }
+
+ let(:project) { issue.project }
+ let(:current_user) { owner }
+
+ let(:query) { make_query }
+
+ def make_query(vq = version_query)
+ graphql_query_for(:project, { fullPath: project.full_path },
+ query_graphql_field(:issue, { iid: issue.iid.to_s },
+ query_graphql_field(:design_collection, {}, vq)))
+ end
+
+ let(:design_collection) do
+ graphql_data_at(:project, :issue, :design_collection)
+ end
+
+ def response_values(data = graphql_data, key = 'sha')
+ path = %w[project issue designCollection versions edges]
+ data.dig(*path).map { |e| e.dig('node', key) }
+ end
+
+ before do
+ enable_design_management
+ end
+
+ it 'returns the design filename' do
+ post_graphql(query, current_user: current_user)
+
+ expect(response_values).to match_array([version_a, version_b, version_c, version_d].map(&:sha))
+ end
+
+ describe 'filter by sha' do
+ let(:sha) { version_b.sha }
+
+ let(:version_params) { { earlier_or_equal_to_sha: sha } }
+
+ it 'finds only those versions at or before the given cut-off' do
+ post_graphql(query, current_user: current_user)
+
+ expect(response_values).to contain_exactly(version_a.sha, version_b.sha)
+ end
+ end
+
+ describe 'filter by id' do
+ let(:id) { global_id_of(version_c) }
+
+ let(:version_params) { { earlier_or_equal_to_id: id } }
+
+ it 'finds only those versions at or before the given cut-off' do
+ post_graphql(query, current_user: current_user)
+
+ expect(response_values).to contain_exactly(version_a.sha, version_b.sha, version_c.sha)
+ end
+ end
+
+ describe 'pagination' do
+ let(:end_cursor) { design_collection.dig('versions', 'pageInfo', 'endCursor') }
+
+ let(:ids) { issue.design_collection.versions.ordered.map(&:sha) }
+
+ let(:query) { make_query(version_query(first: 2)) }
+
+ let(:cursored_query) do
+ make_query(version_query(after: end_cursor))
+ end
+
+ let(:version_query_fields) { ['pageInfo { endCursor }', 'edges { node { sha } }'] }
+
+ it 'sorts designs for reliable pagination' do
+ post_graphql(query, current_user: current_user)
+
+ expect(response_values).to match_array(ids.take(2))
+
+ post_graphql(cursored_query, current_user: current_user)
+
+ new_data = Gitlab::Json.parse(response.body).fetch('data')
+
+ expect(response_values(new_data)).to match_array(ids.drop(2))
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/issue/designs/designs_spec.rb b/spec/requests/api/graphql/project/issue/designs/designs_spec.rb
new file mode 100644
index 00000000000..b6fd0d91bda
--- /dev/null
+++ b/spec/requests/api/graphql/project/issue/designs/designs_spec.rb
@@ -0,0 +1,388 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Getting designs related to an issue' do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+
+ let_it_be(:design) { create(:design, :with_smaller_image_versions, versions_count: 1) }
+ let_it_be(:current_user) { design.project.owner }
+ let(:design_query) do
+ <<~NODE
+ designs {
+ edges {
+ node {
+ id
+ filename
+ fullPath
+ event
+ image
+ imageV432x230
+ }
+ }
+ }
+ NODE
+ end
+ let(:issue) { design.issue }
+ let(:project) { issue.project }
+ let(:query) { make_query }
+ let(:design_collection) do
+ graphql_data_at(:project, :issue, :design_collection)
+ end
+ let(:design_response) do
+ design_collection.dig('designs', 'edges').first['node']
+ end
+
+ def make_query(dq = design_query)
+ designs_field = query_graphql_field(:design_collection, {}, dq)
+ issue_field = query_graphql_field(:issue, { iid: issue.iid.to_s }, designs_field)
+
+ graphql_query_for(:project, { fullPath: project.full_path }, issue_field)
+ end
+
+ def design_image_url(design, ref: nil, size: nil)
+ Gitlab::UrlBuilder.build(design, ref: ref, size: size)
+ end
+
+ context 'when the feature is available' do
+ before do
+ enable_design_management
+ end
+
+ it 'returns the design properties correctly' do
+ version_sha = design.versions.first.sha
+
+ post_graphql(query, current_user: current_user)
+
+ expect(design_response).to eq(
+ 'id' => design.to_global_id.to_s,
+ 'event' => 'CREATION',
+ 'fullPath' => design.full_path,
+ 'filename' => design.filename,
+ 'image' => design_image_url(design, ref: version_sha),
+ 'imageV432x230' => design_image_url(design, ref: version_sha, size: :v432x230)
+ )
+ end
+
+ context 'when the v432x230-sized design image has not been processed' do
+ before do
+ allow_next_instance_of(DesignManagement::DesignV432x230Uploader) do |uploader|
+ allow(uploader).to receive(:file).and_return(nil)
+ end
+ end
+
+ it 'returns nil for the v432x230-sized design image' do
+ post_graphql(query, current_user: current_user)
+
+ expect(design_response['imageV432x230']).to be_nil
+ end
+ end
+
+ describe 'pagination' do
+ before do
+ create_list(:design, 5, :with_file, issue: issue)
+ project.add_developer(current_user)
+ post_graphql(query, current_user: current_user)
+ end
+
+ let(:issue) { create(:issue) }
+
+ let(:end_cursor) { design_collection.dig('designs', 'pageInfo', 'endCursor') }
+
+ let(:ids) { issue.designs.order(:id).map { |d| global_id_of(d) } }
+
+ let(:query) { make_query(designs_fragment(first: 2)) }
+
+ let(:design_query_fields) { 'pageInfo { endCursor } edges { node { id } }' }
+
+ let(:cursored_query) do
+ make_query(designs_fragment(after: end_cursor))
+ end
+
+ def designs_fragment(params)
+ query_graphql_field(:designs, params, design_query_fields)
+ end
+
+ def response_ids(data = graphql_data)
+ path = %w[project issue designCollection designs edges]
+ data.dig(*path).map { |e| e.dig('node', 'id') }
+ end
+
+ it 'sorts designs for reliable pagination' do
+ expect(response_ids).to match_array(ids.take(2))
+
+ post_graphql(cursored_query, current_user: current_user)
+
+ new_data = Gitlab::Json.parse(response.body).fetch('data')
+
+ expect(response_ids(new_data)).to match_array(ids.drop(2))
+ end
+ end
+
+ context 'with versions' do
+ let_it_be(:version) { design.versions.take }
+ let(:design_query) do
+ <<~NODE
+ designs {
+ edges {
+ node {
+ filename
+ versions {
+ edges {
+ node {
+ id
+ sha
+ }
+ }
+ }
+ }
+ }
+ }
+ NODE
+ end
+
+ it 'includes the version id' do
+ post_graphql(query, current_user: current_user)
+
+ version_id = design_response['versions']['edges'].first['node']['id']
+
+ expect(version_id).to eq(version.to_global_id.to_s)
+ end
+
+ it 'includes the version sha' do
+ post_graphql(query, current_user: current_user)
+
+ version_sha = design_response['versions']['edges'].first['node']['sha']
+
+ expect(version_sha).to eq(version.sha)
+ end
+ end
+
+ describe 'viewing a design board at a particular version' do
+ let_it_be(:issue) { design.issue }
+ let_it_be(:second_design, reload: true) { create(:design, :with_smaller_image_versions, issue: issue, versions_count: 1) }
+ let_it_be(:deleted_design) { create(:design, :with_versions, issue: issue, deleted: true, versions_count: 1) }
+ let(:all_versions) { issue.design_versions.ordered.reverse }
+ let(:design_query) do
+ <<~NODE
+ designs(atVersion: "#{version.to_global_id}") {
+ edges {
+ node {
+ id
+ image
+ imageV432x230
+ event
+ versions {
+ edges {
+ node {
+ id
+ }
+ }
+ }
+ }
+ }
+ }
+ NODE
+ end
+ let(:design_response) do
+ design_collection['designs']['edges']
+ end
+
+ def global_id(object)
+ object.to_global_id.to_s
+ end
+
+ # Filters just design nodes from the larger `design_response`
+ def design_nodes
+ design_response.map do |response|
+ response['node']
+ end
+ end
+
+ # Filters just version nodes from the larger `design_response`
+ def version_nodes
+ design_response.map do |response|
+ response.dig('node', 'versions', 'edges')
+ end
+ end
+
+ context 'viewing the original version, when one design was created' do
+ let(:version) { all_versions.first }
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'only returns the first design' do
+ expect(design_nodes).to contain_exactly(
+ a_hash_including('id' => global_id(design))
+ )
+ end
+
+ it 'returns the correct full-sized design image' do
+ expect(design_nodes).to contain_exactly(
+ a_hash_including('image' => design_image_url(design, ref: version.sha))
+ )
+ end
+
+ it 'returns the correct v432x230-sized design image' do
+ expect(design_nodes).to contain_exactly(
+ a_hash_including('imageV432x230' => design_image_url(design, ref: version.sha, size: :v432x230))
+ )
+ end
+
+ it 'returns the correct event for the design in this version' do
+ expect(design_nodes).to contain_exactly(
+ a_hash_including('event' => 'CREATION')
+ )
+ end
+
+ it 'only returns one version record for the design (the original version)' do
+ expect(version_nodes).to eq([
+ [{ 'node' => { 'id' => global_id(version) } }]
+ ])
+ end
+ end
+
+ context 'viewing the second version, when one design was created' do
+ let(:version) { all_versions.second }
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'only returns the first two designs' do
+ expect(design_nodes).to contain_exactly(
+ a_hash_including('id' => global_id(design)),
+ a_hash_including('id' => global_id(second_design))
+ )
+ end
+
+ it 'returns the correct full-sized design images' do
+ expect(design_nodes).to contain_exactly(
+ a_hash_including('image' => design_image_url(design, ref: version.sha)),
+ a_hash_including('image' => design_image_url(second_design, ref: version.sha))
+ )
+ end
+
+ it 'returns the correct v432x230-sized design images' do
+ expect(design_nodes).to contain_exactly(
+ a_hash_including('imageV432x230' => design_image_url(design, ref: version.sha, size: :v432x230)),
+ a_hash_including('imageV432x230' => design_image_url(second_design, ref: version.sha, size: :v432x230))
+ )
+ end
+
+ it 'returns the correct events for the designs in this version' do
+ expect(design_nodes).to contain_exactly(
+ a_hash_including('event' => 'NONE'),
+ a_hash_including('event' => 'CREATION')
+ )
+ end
+
+ it 'returns the correct versions records for both designs' do
+ expect(version_nodes).to eq([
+ [{ 'node' => { 'id' => global_id(design.versions.first) } }],
+ [{ 'node' => { 'id' => global_id(second_design.versions.first) } }]
+ ])
+ end
+ end
+
+ context 'viewing the last version, when one design was deleted and one was updated' do
+ let(:version) { all_versions.last }
+ let!(:second_design_update) do
+ create(:design_action, :with_image_v432x230, design: second_design, version: version, event: 'modification')
+ end
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'does not include the deleted design' do
+ # The design does exist in the version
+ expect(version.designs).to include(deleted_design)
+
+ # But the GraphQL API does not include it in these results
+ expect(design_nodes).to contain_exactly(
+ a_hash_including('id' => global_id(design)),
+ a_hash_including('id' => global_id(second_design))
+ )
+ end
+
+ it 'returns the correct full-sized design images' do
+ expect(design_nodes).to contain_exactly(
+ a_hash_including('image' => design_image_url(design, ref: version.sha)),
+ a_hash_including('image' => design_image_url(second_design, ref: version.sha))
+ )
+ end
+
+ it 'returns the correct v432x230-sized design images' do
+ expect(design_nodes).to contain_exactly(
+ a_hash_including('imageV432x230' => design_image_url(design, ref: version.sha, size: :v432x230)),
+ a_hash_including('imageV432x230' => design_image_url(second_design, ref: version.sha, size: :v432x230))
+ )
+ end
+
+ it 'returns the correct events for the designs in this version' do
+ expect(design_nodes).to contain_exactly(
+ a_hash_including('event' => 'NONE'),
+ a_hash_including('event' => 'MODIFICATION')
+ )
+ end
+
+ it 'returns all versions records for the designs' do
+ expect(version_nodes).to eq([
+ [
+ { 'node' => { 'id' => global_id(design.versions.first) } }
+ ],
+ [
+ { 'node' => { 'id' => global_id(second_design.versions.second) } },
+ { 'node' => { 'id' => global_id(second_design.versions.first) } }
+ ]
+ ])
+ end
+ end
+ end
+
+ describe 'a design with note annotations' do
+ let_it_be(:note) { create(:diff_note_on_design, noteable: design) }
+
+ let(:design_query) do
+ <<~NODE
+ designs {
+ edges {
+ node {
+ notesCount
+ notes {
+ edges {
+ node {
+ id
+ }
+ }
+ }
+ }
+ }
+ }
+ NODE
+ end
+
+ let(:design_response) do
+ design_collection['designs']['edges'].first['node']
+ end
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'returns the notes for the design' do
+ expect(design_response.dig('notes', 'edges')).to eq(
+ ['node' => { 'id' => note.to_global_id.to_s }]
+ )
+ end
+
+ it 'returns a note_count for the design' do
+ expect(design_response['notesCount']).to eq(1)
+ 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
new file mode 100644
index 00000000000..0207bb9123a
--- /dev/null
+++ b/spec/requests/api/graphql/project/issue/designs/notes_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Getting designs related to an issue' do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:design) { create(:design, :with_file, versions_count: 1, issue: issue) }
+ let_it_be(:current_user) { project.owner }
+ let_it_be(:note) { create(:diff_note_on_design, noteable: design, project: project) }
+
+ before do
+ enable_design_management
+
+ note
+ end
+
+ it_behaves_like 'a working graphql query' do
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+ end
+
+ it 'is not too deep for anonymous users' do
+ note_fields = <<~FIELDS
+ id
+ author { name }
+ FIELDS
+
+ post_graphql(query(note_fields), current_user: nil)
+
+ designs_data = graphql_data['project']['issue']['designs']['designs']
+ design_data = designs_data['edges'].first['node']
+ note_data = design_data['notes']['edges'].first['node']
+
+ expect(note_data['id']).to eq(note.to_global_id.to_s)
+ end
+
+ def query(note_fields = all_graphql_fields_for(Note))
+ design_node = <<~NODE
+ designs {
+ edges {
+ node {
+ notes {
+ edges {
+ node {
+ #{note_fields}
+ }
+ }
+ }
+ }
+ }
+ }
+ NODE
+ graphql_query_for(
+ 'project',
+ { 'fullPath' => design.project.full_path },
+ query_graphql_field(
+ 'issue',
+ { iid: design.issue.iid.to_s },
+ query_graphql_field(
+ 'designs', {}, design_node
+ )
+ )
+ )
+ end
+end
diff --git a/spec/requests/api/graphql/project/issue_spec.rb b/spec/requests/api/graphql/project/issue_spec.rb
new file mode 100644
index 00000000000..92d2f9d0d31
--- /dev/null
+++ b/spec/requests/api/graphql/project/issue_spec.rb
@@ -0,0 +1,189 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Query.project(fullPath).issue(iid)' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:issue_b) { create(:issue, project: project) }
+ let_it_be(:developer) { create(:user) }
+ let(:current_user) { developer }
+
+ let_it_be(:project_params) { { 'fullPath' => project.full_path } }
+ let_it_be(:issue_params) { { 'iid' => issue.iid.to_s } }
+ let_it_be(:issue_fields) { 'title' }
+
+ let(:query) do
+ graphql_query_for('project', project_params, project_fields)
+ end
+
+ let(:project_fields) do
+ query_graphql_field(:issue, issue_params, issue_fields)
+ end
+
+ shared_examples 'being able to fetch a design-like object by ID' do
+ let(:design) { design_a }
+ let(:path) { %w[project issue designCollection] + [GraphqlHelpers.fieldnamerize(object_field_name)] }
+
+ let(:design_fields) do
+ [
+ query_graphql_field(:filename),
+ query_graphql_field(:project, nil, query_graphql_field(:id))
+ ]
+ end
+
+ let(:design_collection_fields) do
+ query_graphql_field(object_field_name, object_params, object_fields)
+ end
+
+ let(:object_fields) { design_fields }
+
+ context 'the ID is passed' do
+ let(:object_params) { { id: global_id_of(object) } }
+ let(:result_fields) { {} }
+
+ let(:expected_fields) do
+ result_fields.merge({ 'filename' => design.filename, 'project' => id_hash(project) })
+ end
+
+ it 'retrieves the object' do
+ post_query
+
+ data = graphql_data.dig(*path)
+
+ expect(data).to match(a_hash_including(expected_fields))
+ end
+
+ context 'the user is unauthorized' do
+ let(:current_user) { create(:user) }
+
+ it_behaves_like 'a failure to find anything'
+ end
+ end
+
+ context 'without parameters' do
+ let(:object_params) { nil }
+
+ it 'raises an error' do
+ post_query
+
+ expect(graphql_errors).to include(no_argument_error)
+ end
+ end
+
+ context 'attempting to retrieve an object from a different issue' do
+ let(:object_params) { { id: global_id_of(object_on_other_issue) } }
+
+ it_behaves_like 'a failure to find anything'
+ end
+ end
+
+ before do
+ project.add_developer(developer)
+ end
+
+ let(:post_query) { post_graphql(query, current_user: current_user) }
+
+ describe '.designCollection' do
+ include DesignManagementTestHelpers
+
+ let_it_be(:design_a) { create(:design, issue: issue) }
+ let_it_be(:version_a) { create(:design_version, issue: issue, created_designs: [design_a]) }
+
+ let(:issue_fields) do
+ query_graphql_field(:design_collection, dc_params, design_collection_fields)
+ end
+
+ let(:dc_params) { nil }
+ let(:design_collection_fields) { nil }
+
+ before do
+ enable_design_management
+ end
+
+ describe '.design' do
+ let(:object) { design }
+ let(:object_field_name) { :design }
+
+ let(:no_argument_error) do
+ custom_graphql_error(path, a_string_matching(%r/id or filename/))
+ end
+
+ let_it_be(:object_on_other_issue) { create(:design, issue: issue_b) }
+
+ it_behaves_like 'being able to fetch a design-like object by ID'
+
+ it_behaves_like 'being able to fetch a design-like object by ID' do
+ let(:object_params) { { filename: design.filename } }
+ end
+ end
+
+ describe '.version' do
+ let(:version) { version_a }
+ let(:path) { %w[project issue designCollection version] }
+
+ let(:design_collection_fields) do
+ query_graphql_field(:version, version_params, 'id sha')
+ end
+
+ context 'no parameters' do
+ let(:version_params) { nil }
+
+ it 'raises an error' do
+ post_query
+
+ expect(graphql_errors).to include(custom_graphql_error(path, a_string_matching(%r/id or sha/)))
+ end
+ end
+
+ shared_examples 'a successful query for a version' do
+ it 'finds the version' do
+ post_query
+
+ data = graphql_data.dig(*path)
+
+ expect(data).to match(
+ a_hash_including('id' => global_id_of(version),
+ 'sha' => version.sha)
+ )
+ end
+ end
+
+ context '(sha: STRING_TYPE)' do
+ let(:version_params) { { sha: version.sha } }
+
+ it_behaves_like 'a successful query for a version'
+ end
+
+ context '(id: ID_TYPE)' do
+ let(:version_params) { { id: global_id_of(version) } }
+
+ it_behaves_like 'a successful query for a version'
+ end
+ end
+
+ describe '.designAtVersion' do
+ it_behaves_like 'being able to fetch a design-like object by ID' do
+ let(:object) { build(:design_at_version, design: design, version: version) }
+ let(:object_field_name) { :design_at_version }
+
+ let(:version) { version_a }
+
+ let(:result_fields) { { 'version' => id_hash(version) } }
+ let(:object_fields) do
+ design_fields + [query_graphql_field(:version, nil, query_graphql_field(:id))]
+ end
+
+ let(:no_argument_error) { missing_required_argument(path, :id) }
+
+ let(:object_on_other_issue) { build(:design_at_version, issue: issue_b) }
+ end
+ end
+ end
+
+ def id_hash(object)
+ a_hash_including('id' => global_id_of(object))
+ end
+end
diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb
index 4ce7a3912a3..91fce3eed92 100644
--- a/spec/requests/api/graphql/project/issues_spec.rb
+++ b/spec/requests/api/graphql/project/issues_spec.rb
@@ -45,8 +45,8 @@ describe 'getting an issue list for a project' do
it 'includes discussion locked' do
post_graphql(query, current_user: current_user)
- expect(issues_data[0]['node']['discussionLocked']).to eq false
- expect(issues_data[1]['node']['discussionLocked']).to eq true
+ expect(issues_data[0]['node']['discussionLocked']).to eq(false)
+ expect(issues_data[1]['node']['discussionLocked']).to eq(true)
end
context 'when limiting the number of results' do
@@ -79,7 +79,7 @@ describe 'getting an issue list for a project' do
post_graphql(query)
- expect(issues_data).to eq []
+ expect(issues_data).to eq([])
end
end
@@ -118,131 +118,138 @@ describe 'getting an issue list for a project' do
end
describe 'sorting and pagination' do
- let(:start_cursor) { graphql_data['project']['issues']['pageInfo']['startCursor'] }
- let(:end_cursor) { graphql_data['project']['issues']['pageInfo']['endCursor'] }
+ let_it_be(:data_path) { [:project, :issues] }
- context 'when sorting by due date' do
- let(:sort_project) { create(:project, :public) }
-
- let!(:due_issue1) { create(:issue, project: sort_project, due_date: 3.days.from_now) }
- let!(:due_issue2) { create(:issue, project: sort_project, due_date: nil) }
- let!(:due_issue3) { create(:issue, project: sort_project, due_date: 2.days.ago) }
- let!(:due_issue4) { create(:issue, project: sort_project, due_date: nil) }
- let!(:due_issue5) { create(:issue, project: sort_project, due_date: 1.day.ago) }
-
- let(:params) { 'sort: DUE_DATE_ASC' }
-
- def query(issue_params = params)
- graphql_query_for(
- 'project',
- { 'fullPath' => sort_project.full_path },
- <<~ISSUES
- issues(#{issue_params}) {
- pageInfo {
- endCursor
- }
- edges {
- node {
- iid
- dueDate
- }
- }
- }
- ISSUES
- )
- end
+ def pagination_query(params, page_info)
+ graphql_query_for(
+ 'project',
+ { 'fullPath' => sort_project.full_path },
+ "issues(#{params}) { #{page_info} edges { node { iid dueDate } } }"
+ )
+ end
- before do
- post_graphql(query, current_user: current_user)
- end
+ def pagination_results_data(data)
+ data.map { |issue| issue.dig('node', 'iid').to_i }
+ end
- it_behaves_like 'a working graphql query'
+ context 'when sorting by due date' do
+ let_it_be(:sort_project) { create(:project, :public) }
+ let_it_be(:due_issue1) { create(:issue, project: sort_project, due_date: 3.days.from_now) }
+ let_it_be(:due_issue2) { create(:issue, project: sort_project, due_date: nil) }
+ let_it_be(:due_issue3) { create(:issue, project: sort_project, due_date: 2.days.ago) }
+ let_it_be(:due_issue4) { create(:issue, project: sort_project, due_date: nil) }
+ let_it_be(:due_issue5) { create(:issue, project: sort_project, due_date: 1.day.ago) }
context 'when ascending' do
- it 'sorts issues' do
- expect(grab_iids).to eq [due_issue3.iid, due_issue5.iid, due_issue1.iid, due_issue4.iid, due_issue2.iid]
- end
-
- context 'when paginating' do
- let(:params) { 'sort: DUE_DATE_ASC, first: 2' }
-
- it 'sorts issues' do
- expect(grab_iids).to eq [due_issue3.iid, due_issue5.iid]
-
- cursored_query = query("sort: DUE_DATE_ASC, after: \"#{end_cursor}\"")
- post_graphql(cursored_query, current_user: current_user)
- response_data = JSON.parse(response.body)['data']['project']['issues']['edges']
-
- expect(grab_iids(response_data)).to eq [due_issue1.iid, due_issue4.iid, due_issue2.iid]
- end
+ it_behaves_like 'sorted paginated query' do
+ let(:sort_param) { 'DUE_DATE_ASC' }
+ let(:first_param) { 2 }
+ let(:expected_results) { [due_issue3.iid, due_issue5.iid, due_issue1.iid, due_issue4.iid, due_issue2.iid] }
end
end
context 'when descending' do
- let(:params) { 'sort: DUE_DATE_DESC' }
-
- it 'sorts issues' do
- expect(grab_iids).to eq [due_issue1.iid, due_issue5.iid, due_issue3.iid, due_issue4.iid, due_issue2.iid]
+ it_behaves_like 'sorted paginated query' do
+ let(:sort_param) { 'DUE_DATE_DESC' }
+ let(:first_param) { 2 }
+ let(:expected_results) { [due_issue1.iid, due_issue5.iid, due_issue3.iid, due_issue4.iid, due_issue2.iid] }
end
+ end
+ end
- context 'when paginating' do
- let(:params) { 'sort: DUE_DATE_DESC, first: 2' }
-
- it 'sorts issues' do
- expect(grab_iids).to eq [due_issue1.iid, due_issue5.iid]
-
- cursored_query = query("sort: DUE_DATE_DESC, after: \"#{end_cursor}\"")
- post_graphql(cursored_query, current_user: current_user)
- response_data = JSON.parse(response.body)['data']['project']['issues']['edges']
+ context 'when sorting by relative position' do
+ let_it_be(:sort_project) { create(:project, :public) }
+ let_it_be(:relative_issue1) { create(:issue, project: sort_project, relative_position: 2000) }
+ let_it_be(:relative_issue2) { create(:issue, project: sort_project, relative_position: nil) }
+ let_it_be(:relative_issue3) { create(:issue, project: sort_project, relative_position: 1000) }
+ let_it_be(:relative_issue4) { create(:issue, project: sort_project, relative_position: nil) }
+ let_it_be(:relative_issue5) { create(:issue, project: sort_project, relative_position: 500) }
- expect(grab_iids(response_data)).to eq [due_issue3.iid, due_issue4.iid, due_issue2.iid]
- end
+ context 'when ascending' do
+ it_behaves_like 'sorted paginated query' do
+ let(:sort_param) { 'RELATIVE_POSITION_ASC' }
+ let(:first_param) { 2 }
+ let(:expected_results) { [relative_issue5.iid, relative_issue3.iid, relative_issue1.iid, relative_issue4.iid, relative_issue2.iid] }
end
end
end
- context 'when sorting by relative position' do
- let(:sort_project) { create(:project, :public) }
-
- let!(:relative_issue1) { create(:issue, project: sort_project, relative_position: 2000) }
- let!(:relative_issue2) { create(:issue, project: sort_project, relative_position: nil) }
- let!(:relative_issue3) { create(:issue, project: sort_project, relative_position: 1000) }
- let!(:relative_issue4) { create(:issue, project: sort_project, relative_position: nil) }
- let!(:relative_issue5) { create(:issue, project: sort_project, relative_position: 500) }
-
- let(:params) { 'sort: RELATIVE_POSITION_ASC' }
-
- def query(issue_params = params)
- graphql_query_for(
- 'project',
- { 'fullPath' => sort_project.full_path },
- "issues(#{issue_params}) { pageInfo { endCursor} edges { node { iid dueDate } } }"
- )
+ context 'when sorting by priority' do
+ let_it_be(:sort_project) { create(:project, :public) }
+ let_it_be(:early_milestone) { create(:milestone, project: sort_project, due_date: 10.days.from_now) }
+ let_it_be(:late_milestone) { create(:milestone, project: sort_project, due_date: 30.days.from_now) }
+ let_it_be(:priority_label1) { create(:label, project: sort_project, priority: 1) }
+ let_it_be(:priority_label2) { create(:label, project: sort_project, priority: 5) }
+ let_it_be(:priority_issue1) { create(:issue, project: sort_project, labels: [priority_label1], milestone: late_milestone) }
+ let_it_be(:priority_issue2) { create(:issue, project: sort_project, labels: [priority_label2]) }
+ let_it_be(:priority_issue3) { create(:issue, project: sort_project, milestone: early_milestone) }
+ let_it_be(:priority_issue4) { create(:issue, project: sort_project) }
+
+ context 'when ascending' do
+ it_behaves_like 'sorted paginated query' do
+ let(:sort_param) { 'PRIORITY_ASC' }
+ let(:first_param) { 2 }
+ let(:expected_results) { [priority_issue3.iid, priority_issue1.iid, priority_issue2.iid, priority_issue4.iid] }
+ end
end
- before do
- post_graphql(query, current_user: current_user)
+ context 'when descending' do
+ it_behaves_like 'sorted paginated query' do
+ let(:sort_param) { 'PRIORITY_DESC' }
+ let(:first_param) { 2 }
+ let(:expected_results) { [priority_issue1.iid, priority_issue3.iid, priority_issue2.iid, priority_issue4.iid] }
+ end
end
+ end
- it_behaves_like 'a working graphql query'
+ context 'when sorting by label priority' do
+ let_it_be(:sort_project) { create(:project, :public) }
+ let_it_be(:label1) { create(:label, project: sort_project, priority: 1) }
+ let_it_be(:label2) { create(:label, project: sort_project, priority: 5) }
+ let_it_be(:label3) { create(:label, project: sort_project, priority: 10) }
+ let_it_be(:label_issue1) { create(:issue, project: sort_project, labels: [label1]) }
+ let_it_be(:label_issue2) { create(:issue, project: sort_project, labels: [label2]) }
+ let_it_be(:label_issue3) { create(:issue, project: sort_project, labels: [label1, label3]) }
+ let_it_be(:label_issue4) { create(:issue, project: sort_project) }
context 'when ascending' do
- it 'sorts issues' do
- expect(grab_iids).to eq [relative_issue5.iid, relative_issue3.iid, relative_issue1.iid, relative_issue4.iid, relative_issue2.iid]
+ it_behaves_like 'sorted paginated query' do
+ let(:sort_param) { 'LABEL_PRIORITY_ASC' }
+ let(:first_param) { 2 }
+ let(:expected_results) { [label_issue3.iid, label_issue1.iid, label_issue2.iid, label_issue4.iid] }
end
+ end
- context 'when paginating' do
- let(:params) { 'sort: RELATIVE_POSITION_ASC, first: 2' }
+ context 'when descending' do
+ it_behaves_like 'sorted paginated query' do
+ let(:sort_param) { 'LABEL_PRIORITY_DESC' }
+ let(:first_param) { 2 }
+ let(:expected_results) { [label_issue2.iid, label_issue3.iid, label_issue1.iid, label_issue4.iid] }
+ end
+ end
+ end
- it 'sorts issues' do
- expect(grab_iids).to eq [relative_issue5.iid, relative_issue3.iid]
+ context 'when sorting by milestone due date' do
+ let_it_be(:sort_project) { create(:project, :public) }
+ let_it_be(:early_milestone) { create(:milestone, project: sort_project, due_date: 10.days.from_now) }
+ let_it_be(:late_milestone) { create(:milestone, project: sort_project, due_date: 30.days.from_now) }
+ let_it_be(:milestone_issue1) { create(:issue, project: sort_project) }
+ let_it_be(:milestone_issue2) { create(:issue, project: sort_project, milestone: early_milestone) }
+ let_it_be(:milestone_issue3) { create(:issue, project: sort_project, milestone: late_milestone) }
- cursored_query = query("sort: RELATIVE_POSITION_ASC, after: \"#{end_cursor}\"")
- post_graphql(cursored_query, current_user: current_user)
- response_data = JSON.parse(response.body)['data']['project']['issues']['edges']
+ context 'when ascending' do
+ it_behaves_like 'sorted paginated query' do
+ let(:sort_param) { 'MILESTONE_DUE_ASC' }
+ let(:first_param) { 2 }
+ let(:expected_results) { [milestone_issue2.iid, milestone_issue3.iid, milestone_issue1.iid] }
+ end
+ end
- expect(grab_iids(response_data)).to eq [relative_issue1.iid, relative_issue4.iid, relative_issue2.iid]
- end
+ context 'when descending' do
+ it_behaves_like 'sorted paginated query' do
+ let(:sort_param) { 'MILESTONE_DUE_DESC' }
+ let(:first_param) { 2 }
+ let(:expected_results) { [milestone_issue3.iid, milestone_issue2.iid, milestone_issue1.iid] }
end
end
end
diff --git a/spec/requests/api/graphql/project/jira_import_spec.rb b/spec/requests/api/graphql/project/jira_import_spec.rb
index 43e1bb13342..e063068eb1a 100644
--- a/spec/requests/api/graphql/project/jira_import_spec.rb
+++ b/spec/requests/api/graphql/project/jira_import_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-describe 'query jira import data' do
+describe 'query Jira import data' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
@@ -18,6 +18,7 @@ describe 'query jira import data' do
jiraImports {
nodes {
jiraProjectKey
+ createdAt
scheduledAt
scheduledBy {
username
diff --git a/spec/requests/api/graphql/query_spec.rb b/spec/requests/api/graphql/query_spec.rb
new file mode 100644
index 00000000000..26b4c6eafd7
--- /dev/null
+++ b/spec/requests/api/graphql/query_spec.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Query' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:developer) { create(:user) }
+ let(:current_user) { developer }
+
+ describe '.designManagement' do
+ include DesignManagementTestHelpers
+
+ let_it_be(:version) { create(:design_version, issue: issue) }
+ let_it_be(:design) { version.designs.first }
+ let(:query_result) { graphql_data.dig(*path) }
+ let(:query) { graphql_query_for(:design_management, nil, dm_fields) }
+
+ before do
+ enable_design_management
+ project.add_developer(developer)
+ post_graphql(query, current_user: current_user)
+ end
+
+ shared_examples 'a query that needs authorization' do
+ context 'the current user is not able to read designs' do
+ let(:current_user) { create(:user) }
+
+ it 'does not retrieve the record' do
+ expect(query_result).to be_nil
+ end
+
+ it 'raises an error' do
+ expect(graphql_errors).to include(
+ a_hash_including('message' => a_string_matching(%r{you don't have permission}))
+ )
+ end
+ end
+ end
+
+ describe '.version' do
+ let(:path) { %w[designManagement version] }
+
+ let(:dm_fields) do
+ query_graphql_field(:version, { 'id' => global_id_of(version) }, 'id sha')
+ end
+
+ it_behaves_like 'a working graphql query'
+ it_behaves_like 'a query that needs authorization'
+
+ context 'the current user is able to read designs' do
+ it 'fetches the expected data' do
+ expect(query_result).to eq('id' => global_id_of(version), 'sha' => version.sha)
+ end
+ end
+ end
+
+ describe '.designAtVersion' do
+ let_it_be(:design_at_version) do
+ ::DesignManagement::DesignAtVersion.new(design: design, version: version)
+ end
+
+ let(:path) { %w[designManagement designAtVersion] }
+
+ let(:dm_fields) do
+ query_graphql_field(:design_at_version, { 'id' => global_id_of(design_at_version) }, <<~FIELDS)
+ id
+ filename
+ version { id sha }
+ design { id }
+ issue { title iid }
+ project { id fullPath }
+ FIELDS
+ end
+
+ it_behaves_like 'a working graphql query'
+ it_behaves_like 'a query that needs authorization'
+
+ context 'the current user is able to read designs' do
+ it 'fetches the expected data, including the correct associations' do
+ expect(query_result).to eq(
+ 'id' => global_id_of(design_at_version),
+ 'filename' => design_at_version.design.filename,
+ 'version' => { 'id' => global_id_of(version), 'sha' => version.sha },
+ 'design' => { 'id' => global_id_of(design) },
+ 'issue' => { 'title' => issue.title, 'iid' => issue.iid.to_s },
+ 'project' => { 'id' => global_id_of(project), 'fullPath' => project.full_path }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql_spec.rb b/spec/requests/api/graphql_spec.rb
index 783dd730dd9..f5c7a820abe 100644
--- a/spec/requests/api/graphql_spec.rb
+++ b/spec/requests/api/graphql_spec.rb
@@ -9,7 +9,7 @@ describe 'GraphQL' do
context 'logging' do
shared_examples 'logging a graphql query' do
let(:expected_params) do
- { query_string: query, variables: variables.to_s, duration: anything, depth: 1, complexity: 1 }
+ { query_string: query, variables: variables.to_s, duration_s: anything, depth: 1, complexity: 1 }
end
it 'logs a query with the expected params' do
diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb
index 30c1f99569b..18feff85482 100644
--- a/spec/requests/api/groups_spec.rb
+++ b/spec/requests/api/groups_spec.rb
@@ -6,15 +6,15 @@ describe API::Groups do
include GroupAPIHelpers
include UploadHelpers
- let(:user1) { create(:user, can_create_group: false) }
- let(:user2) { create(:user) }
- let(:user3) { create(:user) }
- let(:admin) { create(:admin) }
- let!(:group1) { create(:group, avatar: File.open(uploaded_image_temp_path)) }
- let!(:group2) { create(:group, :private) }
- let!(:project1) { create(:project, namespace: group1) }
- let!(:project2) { create(:project, namespace: group2) }
- let!(:project3) { create(:project, namespace: group1, path: 'test', visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
+ let_it_be(:user1) { create(:user, can_create_group: false) }
+ let_it_be(:user2) { create(:user) }
+ let_it_be(:user3) { create(:user) }
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:group1) { create(:group, avatar: File.open(uploaded_image_temp_path)) }
+ let_it_be(:group2) { create(:group, :private) }
+ let_it_be(:project1) { create(:project, namespace: group1) }
+ let_it_be(:project2) { create(:project, namespace: group2) }
+ let_it_be(:project3) { create(:project, namespace: group1, path: 'test', visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
before do
group1.add_owner(user1)
@@ -90,6 +90,17 @@ describe API::Groups do
get api("/groups", admin)
end.not_to exceed_query_limit(control)
end
+
+ context 'when statistics are requested' do
+ it 'does not include statistics' do
+ get api("/groups"), params: { statistics: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.first).not_to include 'statistics'
+ end
+ end
end
context "when authenticated as user" do
@@ -330,7 +341,7 @@ describe API::Groups do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
- expect(response_groups).to eq([group2.id, group3.id])
+ expect(response_groups).to contain_exactly(group2.id, group3.id)
end
end
end
@@ -642,6 +653,33 @@ describe API::Groups do
expect(json_response['default_branch_protection']).to eq(::Gitlab::Access::MAINTAINER_PROJECT_ACCESS)
end
+ context 'updating the `default_branch_protection` attribute' do
+ subject do
+ put api("/groups/#{group1.id}", user1), params: { default_branch_protection: ::Gitlab::Access::PROTECTION_NONE }
+ end
+
+ context 'for users who have the ability to update default_branch_protection' do
+ it 'updates the attribute' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['default_branch_protection']).to eq(Gitlab::Access::PROTECTION_NONE)
+ end
+ end
+
+ context 'for users who does not have the ability to update default_branch_protection`' do
+ it 'does not update the attribute' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user1, :update_default_branch_protection, group1) { false }
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['default_branch_protection']).not_to eq(Gitlab::Access::PROTECTION_NONE)
+ end
+ end
+ end
+
context 'malicious group name' do
subject { put api("/groups/#{group1.id}", user1), params: { name: "<SCRIPT>alert('DOUBLE-ATTACK!')</SCRIPT>" } }
@@ -889,6 +927,181 @@ describe API::Groups do
end
end
+ describe "GET /groups/:id/projects/shared" do
+ let!(:project4) do
+ create(:project, namespace: group2, path: 'test_project', visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+ let(:path) { "/groups/#{group1.id}/projects/shared" }
+
+ before do
+ create(:project_group_link, project: project2, group: group1)
+ create(:project_group_link, project: project4, group: group1)
+ end
+
+ context 'when authenticated as user' do
+ it 'returns the shared projects in the group' do
+ get api(path, user1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response.length).to eq(2)
+ project_ids = json_response.map { |project| project['id'] }
+ expect(project_ids).to match_array([project2.id, project4.id])
+ expect(json_response.first['visibility']).to be_present
+ end
+
+ it 'returns shared projects with min access level or higher' do
+ user = create(:user)
+
+ project2.add_guest(user)
+ project4.add_reporter(user)
+
+ get api(path, user), params: { min_access_level: Gitlab::Access::REPORTER }
+
+ expect(json_response).to be_an(Array)
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(project4.id)
+ end
+
+ it 'returns the shared projects of the group with simple representation' do
+ get api(path, user1), params: { simple: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response.length).to eq(2)
+ project_ids = json_response.map { |project| project['id'] }
+ expect(project_ids).to match_array([project2.id, project4.id])
+ expect(json_response.first['visibility']).not_to be_present
+ end
+
+ it 'filters the shared projects in the group based on visibility' do
+ internal_project = create(:project, :internal, namespace: create(:group))
+
+ create(:project_group_link, project: internal_project, group: group1)
+
+ get api(path, user1), params: { visibility: 'internal' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an(Array)
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(internal_project.id)
+ end
+
+ it 'filters the shared projects in the group based on search params' do
+ get api(path, user1), params: { search: 'test_project' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an(Array)
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(project4.id)
+ end
+
+ it 'does not return the projects owned by the group' do
+ get api(path, user1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an(Array)
+ project_ids = json_response.map { |project| project['id'] }
+
+ expect(project_ids).not_to include(project1.id)
+ end
+
+ it 'returns 404 for a non-existing group' do
+ get api("/groups/0000/projects/shared", user1)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'does not return a group not attached to the user' do
+ group = create(:group, :private)
+
+ get api("/groups/#{group.id}/projects/shared", user1)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'only returns shared projects to which user has access' do
+ project4.add_developer(user3)
+
+ get api(path, user3)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(project4.id)
+ end
+
+ it 'only returns the projects starred by user' do
+ user1.starred_projects = [project2]
+
+ get api(path, user1), params: { starred: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.length).to eq(1)
+ expect(json_response.first['id']).to eq(project2.id)
+ end
+ end
+
+ context "when authenticated as admin" do
+ subject { get api(path, admin) }
+
+ it "returns shared projects of an existing group" do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response.length).to eq(2)
+ project_ids = json_response.map { |project| project['id'] }
+ expect(project_ids).to match_array([project2.id, project4.id])
+ end
+
+ context 'for a non-existent group' do
+ let(:path) { "/groups/000/projects/shared" }
+
+ it 'returns 404 for a non-existent group' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ it 'avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ subject
+ end.count
+
+ create(:project_group_link, project: create(:project), group: group1)
+
+ expect do
+ subject
+ end.not_to exceed_query_limit(control_count)
+ end
+ end
+
+ context 'when using group path in URL' do
+ let(:path) { "/groups/#{group1.path}/projects/shared" }
+
+ it 'returns the right details' do
+ get api(path, admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response.length).to eq(2)
+ project_ids = json_response.map { |project| project['id'] }
+ expect(project_ids).to match_array([project2.id, project4.id])
+ end
+
+ it 'returns 404 for a non-existent group' do
+ get api('/groups/unknown/projects/shared', admin)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
describe 'GET /groups/:id/subgroups' do
let!(:subgroup1) { create(:group, parent: group1) }
let!(:subgroup2) { create(:group, :private, parent: group1) }
@@ -911,6 +1124,17 @@ describe API::Groups do
expect(response).to have_gitlab_http_status(:not_found)
end
+
+ context 'when statistics are requested' do
+ it 'does not include statistics' do
+ get api("/groups/#{group1.id}/subgroups"), params: { statistics: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.first).not_to include 'statistics'
+ end
+ end
end
context 'when authenticated as user' do
@@ -1111,6 +1335,33 @@ describe API::Groups do
it { expect { subject }.not_to change { Group.count } }
end
+ context 'when creating a group with `default_branch_protection` attribute' do
+ let(:params) { attributes_for_group_api default_branch_protection: Gitlab::Access::PROTECTION_NONE }
+
+ subject { post api("/groups", user3), params: params }
+
+ context 'for users who have the ability to create a group with `default_branch_protection`' do
+ it 'creates group with the specified branch protection level' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['default_branch_protection']).to eq(Gitlab::Access::PROTECTION_NONE)
+ end
+ end
+
+ context 'for users who do not have the ability to create a group with `default_branch_protection`' do
+ it 'does not create the group with the specified branch protection level' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user3, :create_group_with_default_branch_protection) { false }
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['default_branch_protection']).not_to eq(Gitlab::Access::PROTECTION_NONE)
+ end
+ end
+ end
+
it "does not create group, duplicate" do
post api("/groups", user3), params: { name: 'Duplicate Test', path: group2.path }
diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb
index 98904a4d79f..d65c89f48ea 100644
--- a/spec/requests/api/helpers_spec.rb
+++ b/spec/requests/api/helpers_spec.rb
@@ -328,6 +328,8 @@ describe API::Helpers do
it 'returns a 401 response' do
expect { authenticate! }.to raise_error /401/
+
+ expect(env[described_class::API_RESPONSE_STATUS_CODE]).to eq(401)
end
end
@@ -340,6 +342,8 @@ describe API::Helpers do
it 'does not raise an error' do
expect { authenticate! }.not_to raise_error
+
+ expect(env[described_class::API_RESPONSE_STATUS_CODE]).to be_nil
end
end
end
diff --git a/spec/requests/api/internal/base_spec.rb b/spec/requests/api/internal/base_spec.rb
index 93c2233e021..684f0329909 100644
--- a/spec/requests/api/internal/base_spec.rb
+++ b/spec/requests/api/internal/base_spec.rb
@@ -323,18 +323,6 @@ describe API::Internal::Base do
end
end
- shared_examples 'snippets with disabled feature flag' do
- context 'when feature flag :version_snippets is disabled' do
- it 'returns 401' do
- stub_feature_flags(version_snippets: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
- end
-
shared_examples 'snippet success' do
it 'responds with success' do
subject
@@ -344,18 +332,6 @@ describe API::Internal::Base do
end
end
- shared_examples 'snippets with web protocol' do
- it_behaves_like 'snippet success'
-
- context 'with disabled version flag' do
- before do
- stub_feature_flags(version_snippets: false)
- end
-
- it_behaves_like 'snippet success'
- end
- end
-
context 'git push with personal snippet' do
subject { push(key, personal_snippet, env: env.to_json, changes: snippet_changes) }
@@ -369,12 +345,6 @@ describe API::Internal::Base do
expect(user.reload.last_activity_on).to be_nil
end
- it_behaves_like 'snippets with disabled feature flag'
-
- it_behaves_like 'snippets with web protocol' do
- subject { push(key, personal_snippet, 'web', env: env.to_json, changes: snippet_changes) }
- end
-
it_behaves_like 'sets hook env' do
let(:gl_repository) { Gitlab::GlRepository::SNIPPET.identifier_for_container(personal_snippet) }
end
@@ -392,12 +362,6 @@ describe API::Internal::Base do
expect(json_response["gl_repository"]).to eq("snippet-#{personal_snippet.id}")
expect(user.reload.last_activity_on).to eql(Date.today)
end
-
- it_behaves_like 'snippets with disabled feature flag'
-
- it_behaves_like 'snippets with web protocol' do
- subject { pull(key, personal_snippet, 'web') }
- end
end
context 'git push with project snippet' do
@@ -413,12 +377,6 @@ describe API::Internal::Base do
expect(user.reload.last_activity_on).to be_nil
end
- it_behaves_like 'snippets with disabled feature flag'
-
- it_behaves_like 'snippets with web protocol' do
- subject { push(key, project_snippet, 'web', env: env.to_json, changes: snippet_changes) }
- end
-
it_behaves_like 'sets hook env' do
let(:gl_repository) { Gitlab::GlRepository::SNIPPET.identifier_for_container(project_snippet) }
end
@@ -434,14 +392,6 @@ describe API::Internal::Base do
expect(json_response["gl_repository"]).to eq("snippet-#{project_snippet.id}")
expect(user.reload.last_activity_on).to eql(Date.today)
end
-
- it_behaves_like 'snippets with disabled feature flag' do
- subject { pull(key, project_snippet) }
- end
-
- it_behaves_like 'snippets with web protocol' do
- subject { pull(key, project_snippet, 'web') }
- end
end
context "git pull" do
@@ -491,23 +441,25 @@ describe API::Internal::Base do
allow(Gitlab::CurrentSettings).to receive(:receive_max_input_size) { 1 }
end
- it 'returns custom git config' do
+ it 'returns maxInputSize and partial clone git config' do
push(key, project)
expect(json_response["git_config_options"]).to be_present
+ expect(json_response["git_config_options"]).to include("receive.maxInputSize=1048576")
expect(json_response["git_config_options"]).to include("uploadpack.allowFilter=true")
expect(json_response["git_config_options"]).to include("uploadpack.allowAnySHA1InWant=true")
end
context 'when gitaly_upload_pack_filter feature flag is disabled' do
before do
- stub_feature_flags(gitaly_upload_pack_filter: { enabled: false, thing: project })
+ stub_feature_flags(gitaly_upload_pack_filter: false)
end
- it 'does not include allowFilter and allowAnySha1InWant in the git config options' do
+ it 'returns only maxInputSize and not partial clone git config' do
push(key, project)
expect(json_response["git_config_options"]).to be_present
+ expect(json_response["git_config_options"]).to include("receive.maxInputSize=1048576")
expect(json_response["git_config_options"]).not_to include("uploadpack.allowFilter=true")
expect(json_response["git_config_options"]).not_to include("uploadpack.allowAnySHA1InWant=true")
end
@@ -515,12 +467,28 @@ describe API::Internal::Base do
end
context 'when receive_max_input_size is empty' do
- it 'returns an empty git config' do
+ before do
allow(Gitlab::CurrentSettings).to receive(:receive_max_input_size) { nil }
+ end
+ it 'returns partial clone git config' do
push(key, project)
- expect(json_response["git_config_options"]).to be_empty
+ expect(json_response["git_config_options"]).to be_present
+ expect(json_response["git_config_options"]).to include("uploadpack.allowFilter=true")
+ expect(json_response["git_config_options"]).to include("uploadpack.allowAnySHA1InWant=true")
+ end
+
+ context 'when gitaly_upload_pack_filter feature flag is disabled' do
+ before do
+ stub_feature_flags(gitaly_upload_pack_filter: false)
+ end
+
+ it 'returns an empty git config' do
+ push(key, project)
+
+ expect(json_response["git_config_options"]).to be_empty
+ end
end
end
end
@@ -949,6 +917,23 @@ describe API::Internal::Base do
expect(json_response['status']).to be_falsy
end
end
+
+ context 'for design repositories' do
+ let(:gl_repository) { Gitlab::GlRepository::DESIGN.identifier_for_container(project) }
+
+ it 'does not allow access' do
+ post(api('/internal/allowed'),
+ params: {
+ key_id: key.id,
+ project: project.full_path,
+ gl_repository: gl_repository,
+ secret_token: secret_token,
+ protocol: 'ssh'
+ })
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
end
describe 'POST /internal/post_receive', :clean_gitlab_redis_shared_state do
diff --git a/spec/requests/api/issues/get_group_issues_spec.rb b/spec/requests/api/issues/get_group_issues_spec.rb
index 3ec5f380390..5c925d2a32e 100644
--- a/spec/requests/api/issues/get_group_issues_spec.rb
+++ b/spec/requests/api/issues/get_group_issues_spec.rb
@@ -3,26 +3,26 @@
require 'spec_helper'
describe API::Issues do
- let_it_be(:user) { create(:user) }
- let(:user2) { create(:user) }
- let(:non_member) { create(:user) }
- let_it_be(:guest) { create(:user) }
- let_it_be(:author) { create(:author) }
- let_it_be(:assignee) { create(:assignee) }
- let(:admin) { create(:user, :admin) }
- let(:issue_title) { 'foo' }
- let(:issue_description) { 'closed' }
- let(:no_milestone_title) { 'None' }
- let(:any_milestone_title) { 'Any' }
+ let_it_be(:user2) { create(:user) }
+ let_it_be(:admin) { create(:user, :admin) }
+ let_it_be(:non_member) { create(:user) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:author) { create(:author) }
+ let_it_be(:assignee) { create(:assignee) }
+ let_it_be(:issue_title) { 'foo' }
+ let_it_be(:issue_description) { 'closed' }
+ let_it_be(:no_milestone_title) { 'None' }
+ let_it_be(:any_milestone_title) { 'Any' }
before do
stub_licensed_features(multiple_issue_assignees: false, issue_weights: false)
end
describe 'GET /groups/:id/issues' do
- let!(:group) { create(:group) }
- let!(:group_project) { create(:project, :public, :repository, creator_id: user.id, namespace: group) }
- let!(:private_mrs_project) do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:group_project) { create(:project, :public, :repository, creator_id: user.id, namespace: group) }
+ let_it_be(:private_mrs_project) do
create(:project, :public, :repository, creator_id: user.id, namespace: group, merge_requests_access_level: ProjectFeature::PRIVATE)
end
@@ -455,6 +455,29 @@ describe API::Issues do
it_behaves_like 'labeled issues with labels and label_name params'
end
+ context 'with archived projects' do
+ let_it_be(:archived_issue) do
+ create(
+ :issue, author: user, assignees: [user],
+ project: create(:project, :public, :archived, creator_id: user.id, namespace: group)
+ )
+ end
+
+ it 'returns only non archived projects issues' do
+ get api(base_url, user)
+
+ expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue.id])
+ end
+
+ it 'returns issues from archived projects if non_archived it set to false' do
+ get api(base_url, user), params: { non_archived: false }
+
+ expect_paginated_array_response(
+ [archived_issue.id, group_closed_issue.id, group_confidential_issue.id, group_issue.id]
+ )
+ end
+ end
+
it 'returns an array of issues found by iids' do
get api(base_url, user), params: { iids: [group_issue.iid] }
diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb
index 00169c1529f..06878f57d43 100644
--- a/spec/requests/api/issues/issues_spec.rb
+++ b/spec/requests/api/issues/issues_spec.rb
@@ -780,28 +780,20 @@ describe API::Issues do
end
context 'filtering by non_archived' do
- let_it_be(:group1) { create(:group) }
- let_it_be(:archived_project) { create(:project, :archived, namespace: group1) }
- let_it_be(:active_project) { create(:project, namespace: group1) }
- let_it_be(:issue1) { create(:issue, project: active_project) }
- let_it_be(:issue2) { create(:issue, project: active_project) }
- let_it_be(:issue3) { create(:issue, project: archived_project) }
+ let_it_be(:archived_project) { create(:project, :archived, creator_id: user.id, namespace: user.namespace) }
+ let_it_be(:archived_issue) { create(:issue, author: user, project: archived_project) }
+ let_it_be(:active_issue) { create(:issue, author: user, project: project) }
- before do
- archived_project.add_developer(user)
- active_project.add_developer(user)
- end
-
- it 'returns issues from non archived projects only by default' do
- get api("/groups/#{group1.id}/issues", user), params: { scope: 'all' }
+ it 'returns issues from non archived projects by default' do
+ get api('/issues', user)
- expect_paginated_array_response([issue2.id, issue1.id])
+ expect_paginated_array_response(active_issue.id, issue.id, closed_issue.id)
end
- it 'returns issues from archived and non archived projects when non_archived is false' do
- get api("/groups/#{group1.id}/issues", user), params: { non_archived: false, scope: 'all' }
+ it 'returns issues from archived project with non_archived set as false' do
+ get api("/issues", user), params: { non_archived: false }
- expect_paginated_array_response([issue3.id, issue2.id, issue1.id])
+ expect_paginated_array_response(active_issue.id, archived_issue.id, issue.id, closed_issue.id)
end
end
end
diff --git a/spec/requests/api/issues/post_projects_issues_spec.rb b/spec/requests/api/issues/post_projects_issues_spec.rb
index 1444f43003f..2e1e5d3204e 100644
--- a/spec/requests/api/issues/post_projects_issues_spec.rb
+++ b/spec/requests/api/issues/post_projects_issues_spec.rb
@@ -403,7 +403,7 @@ describe API::Issues do
end
before do
- expect_next_instance_of(Spam::SpamCheckService) do |spam_service|
+ expect_next_instance_of(Spam::SpamActionService) do |spam_service|
expect(spam_service).to receive_messages(check_for_spam?: true)
end
expect_next_instance_of(Spam::AkismetService) do |akismet_service|
diff --git a/spec/requests/api/issues/put_projects_issues_spec.rb b/spec/requests/api/issues/put_projects_issues_spec.rb
index ffc5e2b1db8..2ab8b9d7877 100644
--- a/spec/requests/api/issues/put_projects_issues_spec.rb
+++ b/spec/requests/api/issues/put_projects_issues_spec.rb
@@ -182,6 +182,8 @@ describe API::Issues do
end
describe 'PUT /projects/:id/issues/:issue_iid with spam filtering' do
+ include_context 'includes Spam constants'
+
def update_issue
put api("/projects/#{project.id}/issues/#{issue.iid}", user), params: params
end
@@ -195,11 +197,12 @@ describe API::Issues do
end
before do
- expect_next_instance_of(Spam::SpamCheckService) do |spam_service|
+ expect_next_instance_of(Spam::SpamActionService) do |spam_service|
expect(spam_service).to receive_messages(check_for_spam?: true)
end
- expect_next_instance_of(Spam::AkismetService) do |akismet_service|
- expect(akismet_service).to receive_messages(spam?: true)
+
+ expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ expect(verdict_service).to receive(:execute).and_return(DISALLOW)
end
end
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index af2ce7f7aef..14b22de9661 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -66,17 +66,36 @@ describe API::MergeRequests do
end
context 'when merge request is unchecked' do
+ let(:check_service_class) { MergeRequests::MergeabilityCheckService }
+ let(:mr_entity) { json_response.find { |mr| mr['id'] == merge_request.id } }
+
before do
merge_request.mark_as_unchecked!
end
- it 'checks mergeability asynchronously' do
- expect_next_instance_of(MergeRequests::MergeabilityCheckService) do |service|
- expect(service).not_to receive(:execute)
- expect(service).to receive(:async_execute)
+ context 'with merge status recheck projection' do
+ it 'checks mergeability asynchronously' do
+ expect_next_instance_of(check_service_class) do |service|
+ expect(service).not_to receive(:execute)
+ expect(service).to receive(:async_execute).and_call_original
+ end
+
+ get(api(endpoint_path, user), params: { with_merge_status_recheck: true })
+
+ expect_successful_response_with_paginated_array
+ expect(mr_entity['merge_status']).to eq('checking')
end
+ end
- get api(endpoint_path, user)
+ context 'without merge status recheck projection' do
+ it 'does not enqueue a merge status recheck' do
+ expect(check_service_class).not_to receive(:new)
+
+ get api(endpoint_path, user)
+
+ expect_successful_response_with_paginated_array
+ expect(mr_entity['merge_status']).to eq('unchecked')
+ end
end
end
@@ -776,8 +795,8 @@ describe API::MergeRequests do
end
describe "GET /groups/:id/merge_requests" do
- let!(:group) { create(:group, :public) }
- let!(:project) { create(:project, :public, :repository, creator: user, namespace: group, only_allow_merge_if_pipeline_succeeds: false) }
+ 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) }
let(:endpoint_path) { "/groups/#{group.id}/merge_requests" }
before do
@@ -787,9 +806,9 @@ describe API::MergeRequests do
it_behaves_like 'merge requests list'
context 'when have subgroups' do
- let!(:group) { create(:group, :public) }
- let!(:subgroup) { create(:group, parent: group) }
- let!(:project) { create(:project, :public, :repository, creator: user, namespace: subgroup, only_allow_merge_if_pipeline_succeeds: false) }
+ let_it_be(:group) { create(:group, :public) }
+ 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) }
it_behaves_like 'merge requests list'
end
@@ -1535,7 +1554,7 @@ describe API::MergeRequests do
end
context 'forked projects', :sidekiq_might_not_need_inline do
- let!(:user2) { create(:user) }
+ let_it_be(:user2) { create(:user) }
let(:project) { create(:project, :public, :repository) }
let!(:forked_project) { fork_project(project, user2, repository: true) }
let!(:unrelated_project) { create(:project, namespace: create(:user).namespace, creator_id: user2.id) }
@@ -2308,6 +2327,33 @@ describe API::MergeRequests do
end
end
+ context 'with labels' do
+ include_context 'with labels'
+
+ let(:api_base) { api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) }
+
+ it 'when adding labels, keeps existing labels and adds new' do
+ put api_base, params: { add_labels: '1, 2' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['labels']).to contain_exactly(label.title, label2.title, '1', '2')
+ end
+
+ it 'when removing labels, only removes those specified' do
+ put api_base, params: { remove_labels: "#{label.title}" }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['labels']).to eq([label2.title])
+ end
+
+ it 'when removing all labels, keeps no labels' do
+ put api_base, params: { remove_labels: "#{label.title}, #{label2.title}" }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['labels']).to be_empty
+ end
+ end
+
it 'does not update state when title is empty' do
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), params: { state_event: 'close', title: nil }
diff --git a/spec/requests/api/metrics/dashboard/annotations_spec.rb b/spec/requests/api/metrics/dashboard/annotations_spec.rb
index 0b51c46e474..6377ef2435a 100644
--- a/spec/requests/api/metrics/dashboard/annotations_spec.rb
+++ b/spec/requests/api/metrics/dashboard/annotations_spec.rb
@@ -11,77 +11,125 @@ describe API::Metrics::Dashboard::Annotations do
let(:ending_at) { 1.hour.from_now.iso8601 }
let(:params) { attributes_for(:metrics_dashboard_annotation, environment: environment, starting_at: starting_at, ending_at: ending_at, dashboard_path: dashboard)}
- describe 'POST /environments/:environment_id/metrics_dashboard/annotations' do
- before :all do
+ shared_examples 'POST /:source_type/:id/metrics_dashboard/annotations' do |source_type|
+ let(:url) { "/#{source_type.pluralize}/#{source.id}/metrics_dashboard/annotations" }
+
+ before do
project.add_developer(user)
end
- context 'feature flag metrics_dashboard_annotations' do
- context 'is on' do
- before do
- stub_feature_flags(metrics_dashboard_annotations: { enabled: true, thing: project })
- end
- context 'with correct permissions' do
- context 'with valid parameters' do
- it 'creates a new annotation', :aggregate_failures do
- post api("/environments/#{environment.id}/metrics_dashboard/annotations", user), params: params
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['environment_id']).to eq(environment.id)
- expect(json_response['starting_at'].to_time).to eq(starting_at.to_time)
- expect(json_response['ending_at'].to_time).to eq(ending_at.to_time)
- expect(json_response['description']).to eq(params[:description])
- expect(json_response['dashboard_path']).to eq(dashboard)
- end
+ context "with :source_type == #{source_type.pluralize}" do
+ context 'with correct permissions' do
+ context 'with valid parameters' do
+ it 'creates a new annotation', :aggregate_failures do
+ post api(url, user), params: params
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response["#{source_type}_id"]).to eq(source.id)
+ expect(json_response['starting_at'].to_time).to eq(starting_at.to_time)
+ expect(json_response['ending_at'].to_time).to eq(ending_at.to_time)
+ expect(json_response['description']).to eq(params[:description])
+ expect(json_response['dashboard_path']).to eq(dashboard)
end
+ end
- context 'with invalid parameters' do
- it 'returns error messsage' do
- post api("/environments/#{environment.id}/metrics_dashboard/annotations", user),
- params: { dashboard_path: nil, starting_at: nil, description: nil }
+ context 'with invalid parameters' do
+ it 'returns error messsage' do
+ post api(url, user), params: { dashboard_path: '', starting_at: nil, description: nil }
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['message']).to include({ "starting_at" => ["can't be blank"], "description" => ["can't be blank"], "dashboard_path" => ["can't be blank"] })
- end
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to include({ "starting_at" => ["can't be blank"], "description" => ["can't be blank"], "dashboard_path" => ["can't be blank"] })
end
+ end
- context 'with undeclared params' do
- before do
- params[:undeclared_param] = 'xyz'
- end
- it 'filters out undeclared params' do
- expect(::Metrics::Dashboard::Annotations::CreateService).to receive(:new).with(user, hash_excluding(:undeclared_param))
+ context 'with undeclared params' do
+ before do
+ params[:undeclared_param] = 'xyz'
+ end
- post api("/environments/#{environment.id}/metrics_dashboard/annotations", user), params: params
- end
+ it 'filters out undeclared params' do
+ expect(::Metrics::Dashboard::Annotations::CreateService).to receive(:new).with(user, hash_excluding(:undeclared_param))
+
+ post api(url, user), params: params
end
end
- context 'without correct permissions' do
- let_it_be(:guest) { create(:user) }
+ context 'with special characers in dashboard_path in request body' do
+ let(:dashboard_escaped) { 'config/prometheus/common_metrics%26copy.yml' }
+ let(:dashboard_unescaped) { 'config/prometheus/common_metrics&copy.yml' }
- before do
- project.add_guest(guest)
+ shared_examples 'special characters unescaped' do
+ let(:expected_params) do
+ {
+ 'starting_at' => starting_at.to_time,
+ 'ending_at' => ending_at.to_time,
+ "#{source_type}" => source,
+ 'dashboard_path' => dashboard_unescaped,
+ 'description' => params[:description]
+ }
+ end
+
+ it 'unescapes the dashboard_path', :aggregate_failures do
+ expect(::Metrics::Dashboard::Annotations::CreateService).to receive(:new).with(user, expected_params)
+
+ post api(url, user), params: params
+ end
end
- it 'returns error messsage' do
- post api("/environments/#{environment.id}/metrics_dashboard/annotations", guest), params: params
+ context 'with escaped characters' do
+ it_behaves_like 'special characters unescaped' do
+ let(:dashboard) { dashboard_escaped }
+ end
+ end
- expect(response).to have_gitlab_http_status(:forbidden)
+ context 'with unescaped characers' do
+ it_behaves_like 'special characters unescaped' do
+ let(:dashboard) { dashboard_unescaped }
+ end
end
end
end
- context 'is off' do
+
+ context 'without correct permissions' do
+ let_it_be(:guest) { create(:user) }
+
before do
- stub_feature_flags(metrics_dashboard_annotations: { enabled: false, thing: project })
+ project.add_guest(guest)
end
- it 'returns error messsage' do
- post api("/environments/#{environment.id}/metrics_dashboard/annotations", user), params: params
+ it 'returns error message' do
+ post api(url, guest), params: params
- expect(response).to have_gitlab_http_status(:not_found)
+ expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
end
+
+ describe 'environment' do
+ it_behaves_like 'POST /:source_type/:id/metrics_dashboard/annotations', 'environment' do
+ let(:source) { environment }
+ end
+ end
+
+ describe 'group cluster' do
+ it_behaves_like 'POST /:source_type/:id/metrics_dashboard/annotations', 'cluster' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:cluster) { create(:cluster_for_group, groups: [group]) }
+
+ before do
+ group.add_developer(user)
+ end
+
+ let(:source) { cluster }
+ end
+ end
+
+ describe 'project cluster' do
+ it_behaves_like 'POST /:source_type/:id/metrics_dashboard/annotations', 'cluster' do
+ let_it_be(:cluster) { create(:cluster, projects: [project]) }
+
+ let(:source) { cluster }
+ end
+ end
end
diff --git a/spec/requests/api/metrics/user_starred_dashboards_spec.rb b/spec/requests/api/metrics/user_starred_dashboards_spec.rb
new file mode 100644
index 00000000000..8f9394a0e20
--- /dev/null
+++ b/spec/requests/api/metrics/user_starred_dashboards_spec.rb
@@ -0,0 +1,164 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe API::Metrics::UserStarredDashboards do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:dashboard_yml) { fixture_file('lib/gitlab/metrics/dashboard/sample_dashboard.yml') }
+ let_it_be(:dashboard) { '.gitlab/dashboards/find&seek.yml' }
+ let_it_be(:project) { create(:project, :private, :repository, :custom_repo, namespace: user.namespace, files: { dashboard => dashboard_yml }) }
+ let(:url) { "/projects/#{project.id}/metrics/user_starred_dashboards" }
+ let(:params) do
+ {
+ dashboard_path: CGI.escape(dashboard)
+ }
+ end
+
+ describe 'POST /projects/:id/metrics/user_starred_dashboards' do
+ before do
+ project.add_reporter(user)
+ end
+
+ context 'with correct permissions' do
+ context 'with valid parameters' do
+ context 'dashboard_path as url param url escaped' do
+ it 'creates a new user starred metrics dashboard', :aggregate_failures do
+ post api(url, user), params: params
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['project_id']).to eq(project.id)
+ expect(json_response['user_id']).to eq(user.id)
+ expect(json_response['dashboard_path']).to eq(dashboard)
+ end
+ end
+
+ context 'dashboard_path in request body unescaped' do
+ let(:params) do
+ {
+ dashboard_path: dashboard
+ }
+ end
+
+ it 'creates a new user starred metrics dashboard', :aggregate_failures do
+ post api(url, user), params: params
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['project_id']).to eq(project.id)
+ expect(json_response['user_id']).to eq(user.id)
+ expect(json_response['dashboard_path']).to eq(dashboard)
+ end
+ end
+ end
+
+ context 'with invalid parameters' do
+ it 'returns error message' do
+ post api(url, user), params: { dashboard_path: '' }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('dashboard_path is empty')
+ end
+
+ context 'user is missing' do
+ it 'returns 404 not found' do
+ post api(url, nil), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'project is missing' do
+ it 'returns 404 not found' do
+ post api("/projects/#{project.id + 1}/user_starred_dashboards", user), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
+ context 'without correct permissions' do
+ it 'returns 404 not found' do
+ post api(url, create(:user)), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'DELETE /projects/:id/metrics/user_starred_dashboards' do
+ let_it_be(:user_starred_dashboard_1) { create(:metrics_users_starred_dashboard, user: user, project: project, dashboard_path: dashboard) }
+ let_it_be(:user_starred_dashboard_2) { create(:metrics_users_starred_dashboard, user: user, project: project) }
+ let_it_be(:other_user_starred_dashboard) { create(:metrics_users_starred_dashboard, project: project) }
+ let_it_be(:other_project_starred_dashboard) { create(:metrics_users_starred_dashboard, user: user) }
+
+ before do
+ project.add_reporter(user)
+ end
+
+ context 'with correct permissions' do
+ context 'with valid parameters' do
+ context 'dashboard_path as url param url escaped' do
+ it 'deletes given user starred metrics dashboard', :aggregate_failures do
+ delete api(url, user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['deleted_rows']).to eq(1)
+ expect(::Metrics::UsersStarredDashboard.all.pluck(:dashboard_path)).not_to include(dashboard)
+ end
+ end
+
+ context 'dashboard_path in request body unescaped' do
+ let(:params) do
+ {
+ dashboard_path: dashboard
+ }
+ end
+
+ it 'deletes given user starred metrics dashboard', :aggregate_failures do
+ delete api(url, user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['deleted_rows']).to eq(1)
+ expect(::Metrics::UsersStarredDashboard.all.pluck(:dashboard_path)).not_to include(dashboard)
+ end
+ end
+
+ context 'dashboard_path has not been specified' do
+ it 'deletes all starred dashboards for that user within given project', :aggregate_failures do
+ delete api(url, user), params: {}
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['deleted_rows']).to eq(2)
+ expect(::Metrics::UsersStarredDashboard.all).to contain_exactly(other_user_starred_dashboard, other_project_starred_dashboard)
+ end
+ end
+ end
+
+ context 'with invalid parameters' do
+ context 'user is missing' do
+ it 'returns 404 not found' do
+ post api(url, nil), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'project is missing' do
+ it 'returns 404 not found' do
+ post api("/projects/#{project.id + 1}/user_starred_dashboards", user), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
+ context 'without correct permissions' do
+ it 'returns 404 not found' do
+ post api(url, create(:user)), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/pipeline_schedules_spec.rb b/spec/requests/api/pipeline_schedules_spec.rb
index 14b292db045..98eaf36b14e 100644
--- a/spec/requests/api/pipeline_schedules_spec.rb
+++ b/spec/requests/api/pipeline_schedules_spec.rb
@@ -67,7 +67,7 @@ describe API::PipelineSchedules do
end
def active?(str)
- (str == 'active') ? true : false
+ str == 'active'
end
end
end
diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb
index f43fa5b4185..f57223f1de5 100644
--- a/spec/requests/api/pipelines_spec.rb
+++ b/spec/requests/api/pipelines_spec.rb
@@ -72,8 +72,8 @@ describe API::Pipelines do
end
context 'when scope is branches or tags' do
- let!(:pipeline_branch) { create(:ci_pipeline, project: project) }
- let!(:pipeline_tag) { create(:ci_pipeline, project: project, ref: 'v1.0.0', tag: true) }
+ let_it_be(:pipeline_branch) { create(:ci_pipeline, project: project) }
+ let_it_be(:pipeline_tag) { create(:ci_pipeline, project: project, ref: 'v1.0.0', tag: true) }
context 'when scope is branches' do
it 'returns matched pipelines' do
@@ -161,7 +161,7 @@ describe API::Pipelines do
end
context 'when name is specified' do
- let!(:pipeline) { create(:ci_pipeline, project: project, user: user) }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
context 'when name exists' do
it 'returns matched pipelines' do
@@ -185,7 +185,7 @@ describe API::Pipelines do
end
context 'when username is specified' do
- let!(:pipeline) { create(:ci_pipeline, project: project, user: user) }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
context 'when username exists' do
it 'returns matched pipelines' do
@@ -209,8 +209,8 @@ describe API::Pipelines do
end
context 'when yaml_errors is specified' do
- let!(:pipeline1) { create(:ci_pipeline, project: project, yaml_errors: 'Syntax error') }
- let!(:pipeline2) { create(:ci_pipeline, project: project) }
+ let_it_be(:pipeline1) { create(:ci_pipeline, project: project, yaml_errors: 'Syntax error') }
+ let_it_be(:pipeline2) { create(:ci_pipeline, project: project) }
context 'when yaml_errors is true' do
it 'returns matched pipelines' do
@@ -242,9 +242,9 @@ describe API::Pipelines do
end
context 'when updated_at filters are specified' do
- let!(:pipeline1) { create(:ci_pipeline, project: project, updated_at: 2.days.ago) }
- let!(:pipeline2) { create(:ci_pipeline, project: project, updated_at: 4.days.ago) }
- let!(:pipeline3) { create(:ci_pipeline, project: project, updated_at: 1.hour.ago) }
+ let_it_be(:pipeline1) { create(:ci_pipeline, project: project, updated_at: 2.days.ago) }
+ let_it_be(:pipeline2) { create(:ci_pipeline, project: project, updated_at: 4.days.ago) }
+ let_it_be(:pipeline3) { create(:ci_pipeline, project: project, updated_at: 1.hour.ago) }
it 'returns pipelines with last update date in specified datetime range' do
get api("/projects/#{project.id}/pipelines", user), params: { updated_before: 1.day.ago, updated_after: 3.days.ago }
@@ -614,7 +614,7 @@ describe API::Pipelines do
end
context 'when the pipeline has jobs' do
- let!(:build) { create(:ci_build, project: project, pipeline: pipeline) }
+ let_it_be(:build) { create(:ci_build, project: project, pipeline: pipeline) }
it 'destroys associated jobs' do
delete api("/projects/#{project.id}/pipelines/#{pipeline.id}", owner)
@@ -654,12 +654,12 @@ describe API::Pipelines do
describe 'POST /projects/:id/pipelines/:pipeline_id/retry' do
context 'authorized user' do
- let!(:pipeline) do
+ let_it_be(:pipeline) do
create(:ci_pipeline, project: project, sha: project.commit.id,
ref: project.default_branch)
end
- let!(:build) { create(:ci_build, :failed, pipeline: pipeline) }
+ let_it_be(:build) { create(:ci_build, :failed, pipeline: pipeline) }
it 'retries failed builds' do
expect do
@@ -683,12 +683,12 @@ describe API::Pipelines do
end
describe 'POST /projects/:id/pipelines/:pipeline_id/cancel' do
- let!(:pipeline) do
+ let_it_be(:pipeline) do
create(:ci_empty_pipeline, project: project, sha: project.commit.id,
ref: project.default_branch)
end
- let!(:build) { create(:ci_build, :running, pipeline: pipeline) }
+ let_it_be(:build) { create(:ci_build, :running, pipeline: pipeline) }
context 'authorized user' do
it 'retries failed builds', :sidekiq_might_not_need_inline do
@@ -700,7 +700,7 @@ describe API::Pipelines do
end
context 'user without proper access rights' do
- let!(:reporter) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
before do
project.add_reporter(reporter)
@@ -714,4 +714,73 @@ describe API::Pipelines do
end
end
end
+
+ describe 'GET /projects/:id/pipelines/:pipeline_id/test_report' do
+ context 'authorized user' do
+ subject { get api("/projects/#{project.id}/pipelines/#{pipeline.id}/test_report", user) }
+
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+
+ context 'when feature is enabled' do
+ before do
+ stub_feature_flags(junit_pipeline_view: true)
+ end
+
+ context 'when pipeline does not have a test report' do
+ it 'returns an empty test report' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['total_count']).to eq(0)
+ end
+ end
+
+ context 'when pipeline has a test report' do
+ let(:pipeline) { create(:ci_pipeline, :with_test_reports, project: project) }
+
+ it 'returns the test report' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['total_count']).to eq(4)
+ end
+ end
+
+ context 'when pipeline has corrupt test reports' do
+ before do
+ job = create(:ci_build, pipeline: pipeline)
+ create(:ci_job_artifact, :junit_with_corrupted_data, job: job, project: project)
+ end
+
+ it 'returns a suite_error' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['test_suites'].first['suite_error']).to eq('JUnit XML parsing failed: 1:1: FATAL: Document is empty')
+ end
+ end
+ end
+
+ context 'when feature is disabled' do
+ before do
+ stub_feature_flags(junit_pipeline_view: false)
+ end
+
+ it 'renders empty response' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'does not return project pipelines' do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/test_report", non_member)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq '404 Project Not Found'
+ end
+ end
+ end
end
diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb
index 859a3cca44f..ad872b88664 100644
--- a/spec/requests/api/project_export_spec.rb
+++ b/spec/requests/api/project_export_spec.rb
@@ -411,7 +411,9 @@ describe API::ProjectExport, :clean_gitlab_redis_cache do
it 'starts', :sidekiq_might_not_need_inline do
params = { description: "Foo" }
- expect_any_instance_of(Projects::ImportExport::ExportService).to receive(:execute)
+ expect_next_instance_of(Projects::ImportExport::ExportService) do |service|
+ expect(service).to receive(:execute)
+ end
post api(path, project.owner), params: params
expect(response).to have_gitlab_http_status(:accepted)
diff --git a/spec/requests/api/project_milestones_spec.rb b/spec/requests/api/project_milestones_spec.rb
index a40878fc807..c5911d51706 100644
--- a/spec/requests/api/project_milestones_spec.rb
+++ b/spec/requests/api/project_milestones_spec.rb
@@ -24,13 +24,13 @@ describe API::ProjectMilestones do
project.add_reporter(reporter)
end
- it 'returns 404 response when the project does not exists' do
+ it 'returns 404 response when the project does not exist' do
delete api("/projects/0/milestones/#{milestone.id}", user)
expect(response).to have_gitlab_http_status(:not_found)
end
- it 'returns 404 response when the milestone does not exists' do
+ it 'returns 404 response when the milestone does not exist' do
delete api("/projects/#{project.id}/milestones/0", user)
expect(response).to have_gitlab_http_status(:not_found)
@@ -44,7 +44,7 @@ describe API::ProjectMilestones do
end
describe 'PUT /projects/:id/milestones/:milestone_id to test observer on close' do
- it 'creates an activity event when an milestone is closed' do
+ it 'creates an activity event when a milestone is closed' do
expect(Event).to receive(:create!)
put api("/projects/#{project.id}/milestones/#{milestone.id}", user),
@@ -91,7 +91,7 @@ describe API::ProjectMilestones do
end
end
- context 'when no such resources' do
+ context 'when no such resource' do
before do
group.add_developer(user)
end
diff --git a/spec/requests/api/project_repository_storage_moves_spec.rb b/spec/requests/api/project_repository_storage_moves_spec.rb
new file mode 100644
index 00000000000..7ceea0178f3
--- /dev/null
+++ b/spec/requests/api/project_repository_storage_moves_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe API::ProjectRepositoryStorageMoves do
+ include AccessMatchersForRequest
+
+ let(:user) { create(:admin) }
+ let!(:storage_move) { create(:project_repository_storage_move, :scheduled) }
+
+ describe 'GET /project_repository_storage_moves' do
+ def get_project_repository_storage_moves
+ get api('/project_repository_storage_moves', user)
+ end
+
+ it 'returns project repository storage moves' do
+ get_project_repository_storage_moves
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(response).to match_response_schema('public_api/v4/project_repository_storage_moves')
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['id']).to eq(storage_move.id)
+ expect(json_response.first['state']).to eq(storage_move.human_state_name)
+ end
+
+ it 'avoids N+1 queries', :request_store do
+ # prevent `let` from polluting the control
+ get_project_repository_storage_moves
+
+ control = ActiveRecord::QueryRecorder.new { get_project_repository_storage_moves }
+
+ create(:project_repository_storage_move, :scheduled)
+
+ expect { get_project_repository_storage_moves }.not_to exceed_query_limit(control)
+ end
+
+ it 'returns the most recently created first' do
+ storage_move_oldest = create(:project_repository_storage_move, :scheduled, created_at: 2.days.ago)
+ storage_move_middle = create(:project_repository_storage_move, :scheduled, created_at: 1.day.ago)
+
+ get api('/project_repository_storage_moves', user)
+
+ json_ids = json_response.map {|storage_move| storage_move['id'] }
+ expect(json_ids).to eq([
+ storage_move.id,
+ storage_move_middle.id,
+ storage_move_oldest.id
+ ])
+ end
+
+ describe 'permissions' do
+ it { expect { get_project_repository_storage_moves }.to be_allowed_for(:admin) }
+ it { expect { get_project_repository_storage_moves }.to be_denied_for(:user) }
+ end
+ end
+
+ describe 'GET /project_repository_storage_moves/:id' do
+ let(:project_repository_storage_move_id) { storage_move.id }
+
+ def get_project_repository_storage_move
+ get api("/project_repository_storage_moves/#{project_repository_storage_move_id}", user)
+ end
+
+ it 'returns a project repository storage move' do
+ get_project_repository_storage_move
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/project_repository_storage_move')
+ expect(json_response['id']).to eq(storage_move.id)
+ expect(json_response['state']).to eq(storage_move.human_state_name)
+ end
+
+ context 'non-existent project repository storage move' do
+ let(:project_repository_storage_move_id) { non_existing_record_id }
+
+ it 'returns not found' do
+ get_project_repository_storage_move
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ describe 'permissions' do
+ it { expect { get_project_repository_storage_move }.to be_allowed_for(:admin) }
+ it { expect { get_project_repository_storage_move }.to be_denied_for(:user) }
+ end
+ end
+end
diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb
index 89ade15c1f6..22189dc3299 100644
--- a/spec/requests/api/project_snippets_spec.rb
+++ b/spec/requests/api/project_snippets_spec.rb
@@ -94,21 +94,11 @@ describe API::ProjectSnippets do
expect(json_response['title']).to eq(snippet.title)
expect(json_response['description']).to eq(snippet.description)
- expect(json_response['file_name']).to eq(snippet.file_name)
+ expect(json_response['file_name']).to eq(snippet.file_name_on_repo)
expect(json_response['ssh_url_to_repo']).to eq(snippet.ssh_url_to_repo)
expect(json_response['http_url_to_repo']).to eq(snippet.http_url_to_repo)
end
- context 'when feature flag :version_snippets is disabled' do
- before do
- stub_feature_flags(version_snippets: false)
-
- get api("/projects/#{project.id}/snippets/#{snippet.id}", user)
- end
-
- it_behaves_like 'snippet response without repository URLs'
- end
-
it 'returns 404 for invalid snippet id' do
get api("/projects/#{project.id}/snippets/#{non_existing_record_id}", user)
@@ -129,7 +119,7 @@ describe API::ProjectSnippets do
title: 'Test Title',
file_name: 'test.rb',
description: 'test description',
- code: 'puts "hello world"',
+ content: 'puts "hello world"',
visibility: 'public'
}
end
@@ -148,19 +138,7 @@ describe API::ProjectSnippets do
blob = snippet.repository.blob_at('master', params[:file_name])
- expect(blob.data).to eq params[:code]
- end
-
- context 'when feature flag :version_snippets is disabled' do
- it 'does not create snippet repository' do
- stub_feature_flags(version_snippets: false)
-
- expect do
- subject
- end.to change { ProjectSnippet.count }.by(1)
-
- expect(snippet.repository_exists?).to be_falsey
- end
+ expect(blob.data).to eq params[:content]
end
end
@@ -202,7 +180,7 @@ describe API::ProjectSnippets do
expect(response).to have_gitlab_http_status(:created)
snippet = ProjectSnippet.find(json_response['id'])
- expect(snippet.content).to eq(params[:code])
+ expect(snippet.content).to eq(params[:content])
expect(snippet.description).to eq(params[:description])
expect(snippet.title).to eq(params[:title])
expect(snippet.file_name).to eq(params[:file_name])
@@ -219,7 +197,7 @@ describe API::ProjectSnippets do
expect(response).to have_gitlab_http_status(:created)
snippet = ProjectSnippet.find(json_response['id'])
- expect(snippet.content).to eq(params[:code])
+ expect(snippet.content).to eq(params[:content])
expect(snippet.description).to eq(params[:description])
expect(snippet.title).to eq(params[:title])
expect(snippet.file_name).to eq(params[:file_name])
@@ -230,43 +208,44 @@ describe API::ProjectSnippets do
subject { post api("/projects/#{project.id}/snippets/", admin), params: params }
end
- it 'creates a new snippet with content parameter' do
- params[:content] = params.delete(:code)
+ it 'returns 400 for missing parameters' do
+ params.delete(:title)
post api("/projects/#{project.id}/snippets/", admin), params: params
- expect(response).to have_gitlab_http_status(:created)
- snippet = ProjectSnippet.find(json_response['id'])
- expect(snippet.content).to eq(params[:content])
- expect(snippet.description).to eq(params[:description])
- expect(snippet.title).to eq(params[:title])
- expect(snippet.file_name).to eq(params[:file_name])
- expect(snippet.visibility_level).to eq(Snippet::PUBLIC)
+ expect(response).to have_gitlab_http_status(:bad_request)
end
- it 'returns 400 when both code and content parameters specified' do
- params[:content] = params[:code]
+ it 'returns 400 if content is blank' do
+ params[:content] = ''
post api("/projects/#{project.id}/snippets/", admin), params: params
expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['error']).to eq('code, content are mutually exclusive')
+ expect(json_response['error']).to eq 'content is empty'
end
- it 'returns 400 for missing parameters' do
- params.delete(:title)
+ it 'returns 400 if title is blank' do
+ params[:title] = ''
post api("/projects/#{project.id}/snippets/", admin), params: params
expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq 'title is empty'
end
- it 'returns 400 for empty code field' do
- params[:code] = ''
+ context 'when save fails because the repository could not be created' do
+ before do
+ allow_next_instance_of(Snippets::CreateService) do |instance|
+ allow(instance).to receive(:create_repository).and_raise(Snippets::CreateService::CreateRepositoryError)
+ end
+ end
- post api("/projects/#{project.id}/snippets/", admin), params: params
+ it 'returns 400' do
+ post api("/projects/#{project.id}/snippets", admin), params: params
- expect(response).to have_gitlab_http_status(:bad_request)
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
end
context 'when the snippet is spam' do
@@ -320,7 +299,7 @@ describe API::ProjectSnippets do
new_content = 'New content'
new_description = 'New description'
- update_snippet(params: { code: new_content, description: new_description, visibility: 'private' })
+ update_snippet(params: { content: new_content, description: new_description, visibility: 'private' })
expect(response).to have_gitlab_http_status(:ok)
snippet.reload
@@ -341,13 +320,6 @@ describe API::ProjectSnippets do
expect(snippet.description).to eq(new_description)
end
- it 'returns 400 when both code and content parameters specified' do
- update_snippet(params: { code: 'some content', content: 'other content' })
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['error']).to eq('code, content are mutually exclusive')
- end
-
it 'returns 404 for invalid snippet id' do
update_snippet(snippet_id: non_existing_record_id, params: { title: 'foo' })
@@ -361,12 +333,17 @@ describe API::ProjectSnippets do
expect(response).to have_gitlab_http_status(:bad_request)
end
- it 'returns 400 for empty code field' do
- new_content = ''
+ it 'returns 400 if content is blank' do
+ update_snippet(params: { content: '' })
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
- update_snippet(params: { code: new_content })
+ 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 'update with repository actions' do
@@ -460,14 +437,13 @@ describe API::ProjectSnippets do
end
describe 'GET /projects/:project_id/snippets/:id/raw' do
- let(:snippet) { create(:project_snippet, author: admin, project: project) }
+ let_it_be(:snippet) { create(:project_snippet, :repository, author: admin, project: project) }
it 'returns raw text' do
get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/raw", admin)
expect(response).to have_gitlab_http_status(:ok)
expect(response.content_type).to eq 'text/plain'
- expect(response.body).to eq(snippet.content)
end
it 'returns 404 for invalid snippet id' do
@@ -482,5 +458,11 @@ describe API::ProjectSnippets do
let(:request) { get api("/projects/#{project_no_snippets.id}/snippets/123/raw", admin) }
end
end
+
+ it_behaves_like 'snippet blob content' do
+ let_it_be(:snippet_with_empty_repo) { create(:project_snippet, :empty_repo, author: admin, project: project) }
+
+ subject { get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/raw", snippet.author) }
+ end
end
end
diff --git a/spec/requests/api/project_statistics_spec.rb b/spec/requests/api/project_statistics_spec.rb
index 1f48c081043..89809a97b96 100644
--- a/spec/requests/api/project_statistics_spec.rb
+++ b/spec/requests/api/project_statistics_spec.rb
@@ -3,23 +3,23 @@
require 'spec_helper'
describe API::ProjectStatistics do
- let(:maintainer) { create(:user) }
- let(:public_project) { create(:project, :public) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:public_project) { create(:project, :public) }
before do
- public_project.add_maintainer(maintainer)
+ public_project.add_developer(developer)
end
describe 'GET /projects/:id/statistics' do
- let!(:fetch_statistics1) { create(:project_daily_statistic, project: public_project, fetch_count: 30, date: 29.days.ago) }
- let!(:fetch_statistics2) { create(:project_daily_statistic, project: public_project, fetch_count: 4, date: 3.days.ago) }
- let!(:fetch_statistics3) { create(:project_daily_statistic, project: public_project, fetch_count: 3, date: 2.days.ago) }
- let!(:fetch_statistics4) { create(:project_daily_statistic, project: public_project, fetch_count: 2, date: 1.day.ago) }
- let!(:fetch_statistics5) { create(:project_daily_statistic, project: public_project, fetch_count: 1, date: Date.today) }
- let!(:fetch_statistics_other_project) { create(:project_daily_statistic, project: create(:project), fetch_count: 29, date: 29.days.ago) }
+ let_it_be(:fetch_statistics1) { create(:project_daily_statistic, project: public_project, fetch_count: 30, date: 29.days.ago) }
+ let_it_be(:fetch_statistics2) { create(:project_daily_statistic, project: public_project, fetch_count: 4, date: 3.days.ago) }
+ let_it_be(:fetch_statistics3) { create(:project_daily_statistic, project: public_project, fetch_count: 3, date: 2.days.ago) }
+ let_it_be(:fetch_statistics4) { create(:project_daily_statistic, project: public_project, fetch_count: 2, date: 1.day.ago) }
+ let_it_be(:fetch_statistics5) { create(:project_daily_statistic, project: public_project, fetch_count: 1, date: Date.today) }
+ let_it_be(:fetch_statistics_other_project) { create(:project_daily_statistic, project: create(:project), fetch_count: 29, date: 29.days.ago) }
it 'returns the fetch statistics of the last 30 days' do
- get api("/projects/#{public_project.id}/statistics", maintainer)
+ get api("/projects/#{public_project.id}/statistics", developer)
expect(response).to have_gitlab_http_status(:ok)
fetches = json_response['fetches']
@@ -32,7 +32,7 @@ describe API::ProjectStatistics do
it 'excludes the fetch statistics older than 30 days' do
create(:project_daily_statistic, fetch_count: 31, project: public_project, date: 30.days.ago)
- get api("/projects/#{public_project.id}/statistics", maintainer)
+ get api("/projects/#{public_project.id}/statistics", developer)
expect(response).to have_gitlab_http_status(:ok)
fetches = json_response['fetches']
@@ -41,11 +41,11 @@ describe API::ProjectStatistics do
expect(fetches['days'].last).to eq({ 'count' => fetch_statistics1.fetch_count, 'date' => fetch_statistics1.date.to_s })
end
- it 'responds with 403 when the user is not a maintainer of the repository' do
- developer = create(:user)
- public_project.add_developer(developer)
+ it 'responds with 403 when the user is not a developer of the repository' do
+ guest = create(:user)
+ public_project.add_guest(guest)
- get api("/projects/#{public_project.id}/statistics", developer)
+ get api("/projects/#{public_project.id}/statistics", guest)
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response['message']).to eq('403 Forbidden')
diff --git a/spec/requests/api/project_templates_spec.rb b/spec/requests/api/project_templates_spec.rb
index 5dabce20043..caeb465080e 100644
--- a/spec/requests/api/project_templates_spec.rb
+++ b/spec/requests/api/project_templates_spec.rb
@@ -3,15 +3,29 @@
require 'spec_helper'
describe API::ProjectTemplates do
- let_it_be(:public_project) { create(:project, :public) }
+ let_it_be(:public_project) { create(:project, :public, path: 'path.with.dot') }
let_it_be(:private_project) { create(:project, :private) }
let_it_be(:developer) { create(:user) }
+ let(:url_encoded_path) { "#{public_project.namespace.path}%2F#{public_project.path}" }
+
before do
private_project.add_developer(developer)
end
+ shared_examples 'accepts project paths with dots' do
+ it do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
describe 'GET /projects/:id/templates/:type' do
+ it_behaves_like 'accepts project paths with dots' do
+ subject { get api("/projects/#{url_encoded_path}/templates/dockerfiles") }
+ end
+
it 'returns dockerfiles' do
get api("/projects/#{public_project.id}/templates/dockerfiles")
@@ -75,6 +89,10 @@ describe API::ProjectTemplates do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/template_list')
end
+
+ it_behaves_like 'accepts project paths with dots' do
+ subject { get api("/projects/#{url_encoded_path}/templates/licenses") }
+ end
end
describe 'GET /projects/:id/templates/:type/:key' do
@@ -144,6 +162,10 @@ describe API::ProjectTemplates do
expect(response).to match_response_schema('public_api/v4/license')
end
+ it_behaves_like 'accepts project paths with dots' do
+ subject { get api("/projects/#{url_encoded_path}/templates/gitlab_ci_ymls/Android") }
+ end
+
shared_examples 'path traversal attempt' do |template_type|
it 'rejects invalid filenames' do
get api("/projects/#{public_project.id}/templates/#{template_type}/%2e%2e%2fPython%2ea")
@@ -173,5 +195,9 @@ describe API::ProjectTemplates do
expect(content).to include('Project Placeholder')
expect(content).to include("Copyright (C) #{Time.now.year} Fullname Placeholder")
end
+
+ it_behaves_like 'accepts project paths with dots' do
+ subject { get api("/projects/#{url_encoded_path}/templates/licenses/mit") }
+ end
end
end
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index 853155cea7a..0deff138e2e 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -24,7 +24,7 @@ shared_examples 'languages and percentages JSON response' do
get api("/projects/#{project.id}/languages", user)
expect(response).to have_gitlab_http_status(:ok)
- expect(JSON.parse(response.body)).to eq(expected_languages)
+ expect(Gitlab::Json.parse(response.body)).to eq(expected_languages)
end
end
@@ -672,7 +672,7 @@ describe API::Projects do
match[1]
end
- ids += JSON.parse(response.body).map { |p| p['id'] }
+ ids += Gitlab::Json.parse(response.body).map { |p| p['id'] }
end
expect(ids).to contain_exactly(*projects.map(&:id))
@@ -1806,7 +1806,7 @@ describe API::Projects do
first_user = json_response.first
expect(first_user['username']).to eq(user.username)
expect(first_user['name']).to eq(user.name)
- expect(first_user.keys).to contain_exactly(*%w[name username id state avatar_url web_url])
+ expect(first_user.keys).to include(*%w[name username id state avatar_url web_url])
end
end
diff --git a/spec/requests/api/remote_mirrors_spec.rb b/spec/requests/api/remote_mirrors_spec.rb
index 3eaec6e2520..3029b8443b0 100644
--- a/spec/requests/api/remote_mirrors_spec.rb
+++ b/spec/requests/api/remote_mirrors_spec.rb
@@ -78,10 +78,6 @@ describe API::RemoteMirrors do
let(:route) { ->(id) { "/projects/#{project.id}/remote_mirrors/#{id}" } }
let(:mirror) { project.remote_mirrors.first }
- before do
- stub_feature_flags(keep_divergent_refs: false)
- end
-
it 'requires `admin_remote_mirror` permission' do
put api(route[mirror.id], developer)
@@ -100,24 +96,7 @@ describe API::RemoteMirrors do
expect(response).to have_gitlab_http_status(:success)
expect(json_response['enabled']).to eq(false)
expect(json_response['only_protected_branches']).to eq(true)
-
- # Deleted due to lack of feature availability
- expect(json_response['keep_divergent_refs']).to be_nil
- end
-
- context 'with the `keep_divergent_refs` feature enabled' do
- before do
- stub_feature_flags(keep_divergent_refs: { enabled: true, project: project })
- end
-
- it 'updates the `keep_divergent_refs` attribute' do
- project.add_maintainer(user)
-
- put api(route[mirror.id], user), params: { keep_divergent_refs: 'true' }
-
- expect(response).to have_gitlab_http_status(:success)
- expect(json_response['keep_divergent_refs']).to eq(true)
- end
+ expect(json_response['keep_divergent_refs']).to eq(true)
end
end
end
diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb
index bc2da8a2b9a..7284f33f3af 100644
--- a/spec/requests/api/runner_spec.rb
+++ b/spec/requests/api/runner_spec.rb
@@ -471,7 +471,8 @@ describe API::Runner, :clean_gitlab_redis_shared_state do
'sha' => job.sha,
'before_sha' => job.before_sha,
'ref_type' => 'branch',
- 'refspecs' => ["+refs/heads/#{job.ref}:refs/remotes/origin/#{job.ref}"],
+ 'refspecs' => ["+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
+ "+refs/heads/#{job.ref}:refs/remotes/origin/#{job.ref}"],
'depth' => project.ci_default_git_depth }
end
@@ -578,7 +579,9 @@ describe API::Runner, :clean_gitlab_redis_shared_state do
expect(response).to have_gitlab_http_status(:created)
expect(json_response['git_info']['refspecs'])
- .to contain_exactly('+refs/tags/*:refs/tags/*', '+refs/heads/*:refs/remotes/origin/*')
+ .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
+ '+refs/tags/*:refs/tags/*',
+ '+refs/heads/*:refs/remotes/origin/*')
end
end
end
@@ -638,7 +641,9 @@ describe API::Runner, :clean_gitlab_redis_shared_state do
expect(response).to have_gitlab_http_status(:created)
expect(json_response['git_info']['refspecs'])
- .to contain_exactly('+refs/tags/*:refs/tags/*', '+refs/heads/*:refs/remotes/origin/*')
+ .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
+ '+refs/tags/*:refs/tags/*',
+ '+refs/heads/*:refs/remotes/origin/*')
end
end
end
@@ -998,6 +1003,53 @@ describe API::Runner, :clean_gitlab_redis_shared_state do
end
end
+ describe 'a job with excluded artifacts' do
+ context 'when excluded paths are defined' do
+ let(:job) do
+ create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'test',
+ stage: 'deploy', stage_idx: 1,
+ options: { artifacts: { paths: ['abc'], exclude: ['cde'] } })
+ end
+
+ context 'when a runner supports this feature' do
+ it 'exposes excluded paths when the feature is enabled' do
+ stub_feature_flags(ci_artifacts_exclude: true)
+
+ request_job info: { features: { artifacts_exclude: true } }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response.dig('artifacts').first).to include('exclude' => ['cde'])
+ end
+
+ it 'does not expose excluded paths when the feature is disabled' do
+ stub_feature_flags(ci_artifacts_exclude: false)
+
+ request_job info: { features: { artifacts_exclude: true } }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response.dig('artifacts').first).not_to have_key('exclude')
+ end
+ end
+
+ context 'when a runner does not support this feature' do
+ it 'does not expose the build at all' do
+ stub_feature_flags(ci_artifacts_exclude: true)
+
+ request_job
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+ end
+
+ it 'does not expose excluded paths when these are empty' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response.dig('artifacts').first).not_to have_key('exclude')
+ end
+ end
+
def request_job(token = runner.token, **params)
new_params = params.merge(token: token, last_update: last_update)
post api('/jobs/request'), params: new_params, headers: { 'User-Agent' => user_agent }
diff --git a/spec/requests/api/runners_spec.rb b/spec/requests/api/runners_spec.rb
index 164be8f0da6..261e54da6a8 100644
--- a/spec/requests/api/runners_spec.rb
+++ b/spec/requests/api/runners_spec.rb
@@ -3,35 +3,34 @@
require 'spec_helper'
describe API::Runners do
- let(:admin) { create(:user, :admin) }
- let(:user) { create(:user) }
- let(:user2) { create(:user) }
- let(:group_guest) { create(:user) }
- let(:group_reporter) { create(:user) }
- let(:group_developer) { create(:user) }
- let(:group_maintainer) { create(:user) }
-
- let(:project) { create(:project, creator_id: user.id) }
- let(:project2) { create(:project, creator_id: user.id) }
-
- let(:group) { create(:group).tap { |group| group.add_owner(user) } }
- let(:subgroup) { create(:group, parent: group) }
-
- let!(:shared_runner) { create(:ci_runner, :instance, description: 'Shared runner') }
- let!(:project_runner) { create(:ci_runner, :project, description: 'Project runner', projects: [project]) }
- let!(:two_projects_runner) { create(:ci_runner, :project, description: 'Two projects runner', projects: [project, project2]) }
- let!(:group_runner_a) { create(:ci_runner, :group, description: 'Group runner A', groups: [group]) }
- let!(:group_runner_b) { create(:ci_runner, :group, description: 'Group runner B', groups: [subgroup]) }
-
- before do
- # Set project access for users
- create(:group_member, :guest, user: group_guest, group: group)
- create(:group_member, :reporter, user: group_reporter, group: group)
- create(:group_member, :developer, user: group_developer, group: group)
- create(:group_member, :maintainer, user: group_maintainer, group: group)
- create(:project_member, :maintainer, user: user, project: project)
- create(:project_member, :maintainer, user: user, project: project2)
- create(:project_member, :reporter, user: user2, project: project)
+ let_it_be(:admin) { create(:user, :admin) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:user2) { create(:user) }
+ let_it_be(:group_guest) { create(:user) }
+ let_it_be(:group_reporter) { create(:user) }
+ let_it_be(:group_developer) { create(:user) }
+ let_it_be(:group_maintainer) { create(:user) }
+
+ let_it_be(:project) { create(:project, creator_id: user.id) }
+ let_it_be(:project2) { create(:project, creator_id: user.id) }
+
+ let_it_be(:group) { create(:group).tap { |group| group.add_owner(user) } }
+ let_it_be(:subgroup) { create(:group, parent: group) }
+
+ let_it_be(:shared_runner, reload: true) { create(:ci_runner, :instance, description: 'Shared runner') }
+ let_it_be(:project_runner, reload: true) { create(:ci_runner, :project, description: 'Project runner', projects: [project]) }
+ let_it_be(:two_projects_runner) { create(:ci_runner, :project, description: 'Two projects runner', projects: [project, project2]) }
+ let_it_be(:group_runner_a) { create(:ci_runner, :group, description: 'Group runner A', groups: [group]) }
+ let_it_be(:group_runner_b) { create(:ci_runner, :group, description: 'Group runner B', groups: [subgroup]) }
+
+ before_all do
+ group.add_guest(group_guest)
+ group.add_reporter(group_reporter)
+ group.add_developer(group_developer)
+ group.add_maintainer(group_maintainer)
+ project.add_maintainer(user)
+ project2.add_maintainer(user)
+ project.add_reporter(user2)
end
describe 'GET /runners' do
@@ -327,6 +326,32 @@ describe API::Runners do
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
+
+ context 'FF hide_token_from_runners_api is enabled' do
+ before do
+ stub_feature_flags(hide_token_from_runners_api: true)
+ end
+
+ it "does not return runner's token" do
+ get api("/runners/#{shared_runner.id}", admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).not_to have_key('token')
+ end
+ end
+
+ context 'FF hide_token_from_runners_api is disabled' do
+ before do
+ stub_feature_flags(hide_token_from_runners_api: false)
+ end
+
+ it "returns runner's token" do
+ get api("/runners/#{shared_runner.id}", admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to have_key('token')
+ end
+ end
end
describe 'PUT /runners/:id' do
@@ -603,10 +628,10 @@ describe API::Runners do
describe 'GET /runners/:id/jobs' do
let_it_be(:job_1) { create(:ci_build) }
- let!(:job_2) { create(:ci_build, :running, runner: shared_runner, project: project) }
- let!(:job_3) { create(:ci_build, :failed, runner: shared_runner, project: project) }
- let!(:job_4) { create(:ci_build, :running, runner: project_runner, project: project) }
- let!(:job_5) { create(:ci_build, :failed, runner: project_runner, project: project) }
+ let_it_be(:job_2) { create(:ci_build, :running, runner: shared_runner, project: project) }
+ let_it_be(:job_3) { create(:ci_build, :failed, runner: shared_runner, project: project) }
+ let_it_be(:job_4) { create(:ci_build, :running, runner: project_runner, project: project) }
+ let_it_be(:job_5) { create(:ci_build, :failed, runner: project_runner, project: project) }
context 'admin user' do
context 'when runner exists' do
@@ -952,7 +977,7 @@ describe API::Runners do
describe 'POST /projects/:id/runners' do
context 'authorized user' do
- let(:project_runner2) { create(:ci_runner, :project, projects: [project2]) }
+ let_it_be(:project_runner2) { create(:ci_runner, :project, projects: [project2]) }
it 'enables specific runner' do
expect do
diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb
index 6ff5fbd7925..3894e0bf2d1 100644
--- a/spec/requests/api/search_spec.rb
+++ b/spec/requests/api/search_spec.rb
@@ -15,10 +15,36 @@ describe API::Search do
it { expect(json_response.size).to eq(size) }
end
- describe 'GET /search' do
+ shared_examples 'pagination' do |scope:, search: ''|
+ it 'returns a different result for each page' do
+ get api(endpoint, user), params: { scope: scope, search: search, page: 1, per_page: 1 }
+ first = json_response.first
+
+ get api(endpoint, user), params: { scope: scope, search: search, page: 2, per_page: 1 }
+ second = Gitlab::Json.parse(response.body).first
+
+ expect(first).not_to eq(second)
+ end
+
+ it 'returns 1 result when per_page is 1' do
+ get api(endpoint, user), params: { scope: scope, search: search, per_page: 1 }
+
+ expect(json_response.count).to eq(1)
+ end
+
+ it 'returns 2 results when per_page is 2' do
+ get api(endpoint, user), params: { scope: scope, search: search, per_page: 2 }
+
+ expect(Gitlab::Json.parse(response.body).count).to eq(2)
+ end
+ end
+
+ describe 'GET /search' do
+ let(:endpoint) { '/search' }
+
context 'when user is not authenticated' do
it 'returns 401 error' do
- get api('/search'), params: { scope: 'projects', search: 'awesome' }
+ get api(endpoint), params: { scope: 'projects', search: 'awesome' }
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -26,7 +52,7 @@ describe API::Search do
context 'when scope is not supported' do
it 'returns 400 error' do
- get api('/search', user), params: { scope: 'unsupported', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'unsupported', search: 'awesome' }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -34,7 +60,7 @@ describe API::Search do
context 'when scope is missing' do
it 'returns 400 error' do
- get api('/search', user), params: { search: 'awesome' }
+ get api(endpoint, user), params: { search: 'awesome' }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -43,30 +69,48 @@ describe API::Search do
context 'with correct params' do
context 'for projects scope' do
before do
- get api('/search', user), params: { scope: 'projects', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'projects', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/projects'
+
+ it_behaves_like 'pagination', scope: :projects
end
context 'for issues scope' do
before do
create(:issue, project: project, title: 'awesome issue')
- get api('/search', user), params: { scope: 'issues', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'issues', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/issues'
+
+ describe 'pagination' do
+ before do
+ create(:issue, project: project, title: 'another issue')
+ end
+
+ include_examples 'pagination', scope: :issues
+ end
end
context 'for merge_requests scope' do
before do
create(:merge_request, source_project: repo_project, title: 'awesome mr')
- get api('/search', user), params: { scope: 'merge_requests', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'merge_requests', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests'
+
+ 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 'for milestones scope' do
@@ -76,10 +120,18 @@ describe API::Search do
context 'when user can read project milestones' do
before do
- get api('/search', user), params: { scope: 'milestones', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'milestones', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
+
+ describe 'pagination' do
+ before do
+ create(:milestone, project: project, title: 'another milestone')
+ end
+
+ include_examples 'pagination', scope: :milestones
+ end
end
context 'when user cannot read project milestones' do
@@ -89,7 +141,7 @@ describe API::Search do
end
it 'returns empty array' do
- get api('/search', user), params: { scope: 'milestones', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'milestones', search: 'awesome' }
milestones = json_response
@@ -102,16 +154,18 @@ describe API::Search do
before do
create(:user, name: 'billy')
- get api('/search', user), params: { scope: 'users', search: 'billy' }
+ get api(endpoint, user), params: { scope: 'users', search: 'billy' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/user/basics'
+ it_behaves_like 'pagination', scope: :users
+
context 'when users search feature is disabled' do
before do
- allow(Feature).to receive(:disabled?).with(:users_search, default_enabled: true).and_return(true)
+ stub_feature_flags(users_search: false)
- get api('/search', user), params: { scope: 'users', search: 'billy' }
+ get api(endpoint, user), params: { scope: 'users', search: 'billy' }
end
it 'returns 400 error' do
@@ -124,28 +178,28 @@ describe API::Search do
before do
create(:snippet, :public, title: 'awesome snippet', content: 'snippet content')
- get api('/search', user), params: { scope: 'snippet_titles', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'snippet_titles', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/snippets'
- end
- context 'for snippet_blobs scope' do
- before do
- create(:snippet, :public, title: 'awesome snippet', content: 'snippet content')
+ describe 'pagination' do
+ before do
+ create(:snippet, :public, title: 'another snippet', content: 'snippet content')
+ end
- get api('/search', user), params: { scope: 'snippet_blobs', search: 'content' }
+ include_examples 'pagination', scope: :snippet_titles
end
-
- it_behaves_like 'response is correct', schema: 'public_api/v4/snippets'
end
end
end
describe "GET /groups/:id/search" do
+ let(:endpoint) { "/groups/#{group.id}/-/search" }
+
context 'when user is not authenticated' do
it 'returns 401 error' do
- get api("/groups/#{group.id}/search"), params: { scope: 'projects', search: 'awesome' }
+ get api(endpoint), params: { scope: 'projects', search: 'awesome' }
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -153,7 +207,7 @@ describe API::Search do
context 'when scope is not supported' do
it 'returns 400 error' do
- get api("/groups/#{group.id}/search", user), params: { scope: 'unsupported', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'unsupported', search: 'awesome' }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -161,7 +215,7 @@ describe API::Search do
context 'when scope is missing' do
it 'returns 400 error' do
- get api("/groups/#{group.id}/search", user), params: { search: 'awesome' }
+ get api(endpoint, user), params: { search: 'awesome' }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -188,40 +242,66 @@ describe API::Search do
context 'with correct params' do
context 'for projects scope' do
before do
- get api("/groups/#{group.id}/search", user), params: { scope: 'projects', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'projects', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/projects'
+
+ it_behaves_like 'pagination', scope: :projects
end
context 'for issues scope' do
before do
create(:issue, project: project, title: 'awesome issue')
- get api("/groups/#{group.id}/search", user), params: { scope: 'issues', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'issues', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/issues'
+
+ describe 'pagination' do
+ before do
+ create(:issue, project: project, title: 'another issue')
+ end
+
+ include_examples 'pagination', scope: :issues
+ end
end
context 'for merge_requests scope' do
before do
create(:merge_request, source_project: repo_project, title: 'awesome mr')
- get api("/groups/#{group.id}/search", user), params: { scope: 'merge_requests', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'merge_requests', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests'
+
+ 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 'for milestones scope' do
before do
create(:milestone, project: project, title: 'awesome milestone')
- get api("/groups/#{group.id}/search", user), params: { scope: 'milestones', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'milestones', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
+
+ describe 'pagination' do
+ before do
+ create(:milestone, project: project, title: 'another milestone')
+ end
+
+ include_examples 'pagination', scope: :milestones
+ end
end
context 'for milestones scope with group path as id' do
@@ -241,16 +321,24 @@ describe API::Search do
user = create(:user, name: 'billy')
create(:group_member, :developer, user: user, group: group)
- get api("/groups/#{group.id}/search", user), params: { scope: 'users', search: 'billy' }
+ get api(endpoint, user), params: { scope: 'users', search: 'billy' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/user/basics'
+ describe 'pagination' do
+ before do
+ create(:group_member, :developer, group: group)
+ end
+
+ include_examples 'pagination', scope: :users
+ end
+
context 'when users search feature is disabled' do
before do
- allow(Feature).to receive(:disabled?).with(:users_search, default_enabled: true).and_return(true)
+ stub_feature_flags(users_search: false)
- get api("/groups/#{group.id}/search", user), params: { scope: 'users', search: 'billy' }
+ get api(endpoint, user), params: { scope: 'users', search: 'billy' }
end
it 'returns 400 error' do
@@ -273,9 +361,11 @@ describe API::Search do
end
describe "GET /projects/:id/search" do
+ let(:endpoint) { "/projects/#{project.id}/search" }
+
context 'when user is not authenticated' do
it 'returns 401 error' do
- get api("/projects/#{project.id}/search"), params: { scope: 'issues', search: 'awesome' }
+ get api(endpoint), params: { scope: 'issues', search: 'awesome' }
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -283,7 +373,7 @@ describe API::Search do
context 'when scope is not supported' do
it 'returns 400 error' do
- get api("/projects/#{project.id}/search", user), params: { scope: 'unsupported', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'unsupported', search: 'awesome' }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -291,7 +381,7 @@ describe API::Search do
context 'when scope is missing' do
it 'returns 400 error' do
- get api("/projects/#{project.id}/search", user), params: { search: 'awesome' }
+ get api(endpoint, user), params: { search: 'awesome' }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -309,7 +399,7 @@ describe API::Search do
it 'returns 404 error' do
project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
- get api("/projects/#{project.id}/search", user), params: { scope: 'issues', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'issues', search: 'awesome' }
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -320,20 +410,38 @@ describe API::Search do
before do
create(:issue, project: project, title: 'awesome issue')
- get api("/projects/#{project.id}/search", user), params: { scope: 'issues', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'issues', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/issues'
+
+ describe 'pagination' do
+ before do
+ create(:issue, project: project, title: 'another issue')
+ end
+
+ include_examples 'pagination', scope: :issues
+ end
end
context 'for merge_requests scope' do
+ let(:endpoint) { "/projects/#{repo_project.id}/search" }
+
before do
create(:merge_request, source_project: repo_project, title: 'awesome mr')
- get api("/projects/#{repo_project.id}/search", user), params: { scope: 'merge_requests', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'merge_requests', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests'
+
+ 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 'for milestones scope' do
@@ -343,10 +451,18 @@ describe API::Search do
context 'when user can read milestones' do
before do
- get api("/projects/#{project.id}/search", user), params: { scope: 'milestones', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'milestones', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
+
+ describe 'pagination' do
+ before do
+ create(:milestone, project: project, title: 'another milestone')
+ end
+
+ include_examples 'pagination', scope: :milestones
+ end
end
context 'when user cannot read project milestones' do
@@ -356,7 +472,7 @@ describe API::Search do
end
it 'returns empty array' do
- get api("/projects/#{project.id}/search", user), params: { scope: 'milestones', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'milestones', search: 'awesome' }
milestones = json_response
@@ -370,16 +486,24 @@ describe API::Search do
user1 = create(:user, name: 'billy')
create(:project_member, :developer, user: user1, project: project)
- get api("/projects/#{project.id}/search", user), params: { scope: 'users', search: 'billy' }
+ get api(endpoint, user), params: { scope: 'users', search: 'billy' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/user/basics'
+ describe 'pagination' do
+ before do
+ create(:project_member, :developer, project: project)
+ end
+
+ include_examples 'pagination', scope: :users
+ end
+
context 'when users search feature is disabled' do
before do
- allow(Feature).to receive(:disabled?).with(:users_search, default_enabled: true).and_return(true)
+ stub_feature_flags(users_search: false)
- get api("/projects/#{project.id}/search", user), params: { scope: 'users', search: 'billy' }
+ get api(endpoint, user), params: { scope: 'users', search: 'billy' }
end
it 'returns 400 error' do
@@ -392,29 +516,51 @@ describe API::Search do
before do
create(:note_on_merge_request, project: project, note: 'awesome note')
- get api("/projects/#{project.id}/search", user), params: { scope: 'notes', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'notes', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/notes'
+
+ describe 'pagination' do
+ before do
+ mr = create(:merge_request, source_project: project, target_branch: 'another_branch')
+ create(:note, project: project, noteable: mr, note: 'another note')
+ end
+
+ include_examples 'pagination', scope: :notes
+ end
end
context 'for wiki_blobs scope' do
+ let(:wiki) { create(:project_wiki, project: project) }
+
before do
- wiki = create(:project_wiki, project: project)
- create(:wiki_page, wiki: wiki, attrs: { title: 'home', content: "Awesome page" })
+ create(:wiki_page, wiki: wiki, title: 'home', content: "Awesome page")
- get api("/projects/#{project.id}/search", user), params: { scope: 'wiki_blobs', search: 'awesome' }
+ get api(endpoint, user), params: { scope: 'wiki_blobs', search: 'awesome' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/blobs'
+
+ describe 'pagination' do
+ before do
+ create(:wiki_page, wiki: wiki, title: 'home 2', content: 'Another page')
+ end
+
+ include_examples 'pagination', scope: :wiki_blobs, search: 'page'
+ end
end
context 'for commits scope' do
+ let(:endpoint) { "/projects/#{repo_project.id}/search" }
+
before do
- get api("/projects/#{repo_project.id}/search", user), params: { scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' }
+ get api(endpoint, user), params: { scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/commits_details'
+
+ it_behaves_like 'pagination', scope: :commits, search: 'merge'
end
context 'for commits scope with project path as id' do
@@ -426,15 +572,19 @@ describe API::Search do
end
context 'for blobs scope' do
+ let(:endpoint) { "/projects/#{repo_project.id}/search" }
+
before do
- get api("/projects/#{repo_project.id}/search", user), params: { scope: 'blobs', search: 'monitors' }
+ get api(endpoint, user), params: { scope: 'blobs', search: 'monitors' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/blobs', size: 2
+ it_behaves_like 'pagination', scope: :blobs, search: 'monitors'
+
context 'filters' do
it 'by filename' do
- get api("/projects/#{repo_project.id}/search", user), params: { scope: 'blobs', search: 'mon filename:PROCESS.md' }
+ get api(endpoint, user), params: { scope: 'blobs', search: 'mon filename:PROCESS.md' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to eq(2)
@@ -443,21 +593,21 @@ describe API::Search do
end
it 'by path' do
- get api("/projects/#{repo_project.id}/search", user), params: { scope: 'blobs', search: 'mon path:markdown' }
+ get api(endpoint, user), params: { scope: 'blobs', search: 'mon path:markdown' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to eq(8)
end
it 'by extension' do
- get api("/projects/#{repo_project.id}/search", user), params: { scope: 'blobs', search: 'mon extension:md' }
+ get api(endpoint, user), params: { scope: 'blobs', search: 'mon extension:md' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to eq(11)
end
it 'by ref' do
- get api("/projects/#{repo_project.id}/search", user), params: { scope: 'blobs', search: 'This file is used in tests for ci_environments_status', ref: 'pages-deploy' }
+ get api(endpoint, user), params: { scope: 'blobs', search: 'This file is used in tests for ci_environments_status', ref: 'pages-deploy' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to eq(1)
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index 4a8b8f70dff..a5b95bc59a5 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -88,7 +88,9 @@ describe API::Settings, 'Settings' do
allow_local_requests_from_system_hooks: false,
push_event_hooks_limit: 2,
push_event_activities_limit: 2,
- snippet_size_limit: 5
+ snippet_size_limit: 5,
+ issues_create_limit: 300,
+ raw_blob_request_limit: 300
}
expect(response).to have_gitlab_http_status(:ok)
@@ -125,6 +127,8 @@ describe API::Settings, 'Settings' do
expect(json_response['push_event_hooks_limit']).to eq(2)
expect(json_response['push_event_activities_limit']).to eq(2)
expect(json_response['snippet_size_limit']).to eq(5)
+ expect(json_response['issues_create_limit']).to eq(300)
+ expect(json_response['raw_blob_request_limit']).to eq(300)
end
end
@@ -155,6 +159,14 @@ describe API::Settings, 'Settings' do
expect(json_response['allow_local_requests_from_hooks_and_services']).to eq(true)
end
+ it 'disables ability to switch to legacy storage' do
+ put api("/application/settings", admin),
+ params: { hashed_storage_enabled: false }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['hashed_storage_enabled']).to eq(true)
+ end
+
context 'external policy classification settings' do
let(:settings) do
{
diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb
index 3e30dc537e4..c12c95ae2e0 100644
--- a/spec/requests/api/snippets_spec.rb
+++ b/spec/requests/api/snippets_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe API::Snippets do
- let!(:user) { create(:user) }
+ let_it_be(:user) { create(:user) }
describe 'GET /snippets/' do
it 'returns snippets available' do
@@ -90,7 +90,7 @@ describe API::Snippets do
describe 'GET /snippets/:id/raw' do
let_it_be(:author) { create(:user) }
- let_it_be(:snippet) { create(:personal_snippet, :private, author: author) }
+ let_it_be(:snippet) { create(:personal_snippet, :repository, :private, author: author) }
it 'requires authentication' do
get api("/snippets/#{snippet.id}", nil)
@@ -103,7 +103,6 @@ describe API::Snippets do
expect(response).to have_gitlab_http_status(:ok)
expect(response.content_type).to eq 'text/plain'
- expect(response.body).to eq(snippet.content)
end
it 'forces attachment content disposition' do
@@ -134,6 +133,12 @@ describe API::Snippets do
expect(response).to have_gitlab_http_status(:ok)
end
+
+ it_behaves_like 'snippet blob content' do
+ let_it_be(:snippet_with_empty_repo) { create(:personal_snippet, :empty_repo, :private, author: author) }
+
+ subject { get api("/snippets/#{snippet.id}/raw", snippet.author) }
+ end
end
describe 'GET /snippets/:id' do
@@ -155,22 +160,12 @@ describe API::Snippets do
expect(json_response['title']).to eq(private_snippet.title)
expect(json_response['description']).to eq(private_snippet.description)
- expect(json_response['file_name']).to eq(private_snippet.file_name)
+ expect(json_response['file_name']).to eq(private_snippet.file_name_on_repo)
expect(json_response['visibility']).to eq(private_snippet.visibility)
expect(json_response['ssh_url_to_repo']).to eq(private_snippet.ssh_url_to_repo)
expect(json_response['http_url_to_repo']).to eq(private_snippet.http_url_to_repo)
end
- context 'when feature flag :version_snippets is disabled' do
- before do
- stub_feature_flags(version_snippets: false)
-
- get api("/snippets/#{private_snippet.id}", author)
- end
-
- it_behaves_like 'snippet response without repository URLs'
- end
-
it 'shows private snippets to an admin' do
get api("/snippets/#{private_snippet.id}", admin)
@@ -200,7 +195,7 @@ describe API::Snippets do
end
describe 'POST /snippets/' do
- let(:params) do
+ let(:base_params) do
{
title: 'Test Title',
file_name: 'test.rb',
@@ -209,12 +204,14 @@ describe API::Snippets do
visibility: 'public'
}
end
+ let(:params) { base_params.merge(extra_params) }
+ let(:extra_params) { {} }
+
+ subject { post api("/snippets/", user), params: params }
shared_examples 'snippet creation' do
let(:snippet) { Snippet.find(json_response["id"]) }
- subject { post api("/snippets/", user), params: params }
-
it 'creates a new snippet' do
expect do
subject
@@ -240,18 +237,6 @@ describe API::Snippets do
expect(blob.data).to eq params[:content]
end
-
- context 'when feature flag :version_snippets is disabled' do
- it 'does not create snippet repository' do
- stub_feature_flags(version_snippets: false)
-
- expect do
- subject
- end.to change { PersonalSnippet.count }.by(1)
-
- expect(snippet.repository_exists?).to be_falsey
- end
- end
end
context 'with restricted visibility settings' do
@@ -270,7 +255,7 @@ describe API::Snippets do
let(:user) { create(:user, :external) }
it 'does not create a new snippet' do
- post api("/snippets/", user), params: params
+ subject
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -279,16 +264,44 @@ describe API::Snippets do
it 'returns 400 for missing parameters' do
params.delete(:title)
- post api("/snippets/", user), params: params
+ subject
expect(response).to have_gitlab_http_status(:bad_request)
end
- context 'when the snippet is spam' do
- def create_snippet(snippet_params = {})
- post api('/snippets', user), params: params.merge(snippet_params)
+ it 'returns 400 if content is blank' do
+ params[:content] = ''
+
+ subject
+
+ 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
+ params[:title] = ''
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq 'title is empty'
+ end
+
+ context 'when save fails because the repository could not be created' do
+ before do
+ allow_next_instance_of(Snippets::CreateService) do |instance|
+ allow(instance).to receive(:create_repository).and_raise(Snippets::CreateService::CreateRepositoryError)
+ end
end
+ it 'returns 400' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'when the snippet is spam' do
before do
allow_next_instance_of(Spam::AkismetService) do |instance|
allow(instance).to receive(:spam?).and_return(true)
@@ -296,23 +309,25 @@ describe API::Snippets do
end
context 'when the snippet is private' do
+ let(:extra_params) { { visibility: 'private' } }
+
it 'creates the snippet' do
- expect { create_snippet(visibility: 'private') }
- .to change { Snippet.count }.by(1)
+ expect { subject }.to change { Snippet.count }.by(1)
end
end
context 'when the snippet is public' do
+ let(:extra_params) { { visibility: 'public' } }
+
it 'rejects the shippet' do
- expect { create_snippet(visibility: 'public') }
- .not_to change { Snippet.count }
+ expect { subject }.not_to change { Snippet.count }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq({ "error" => "Spam detected" })
end
it 'creates a spam log' do
- expect { create_snippet(visibility: 'public') }
+ expect { subject }
.to log_spam(title: 'Test Title', user_id: user.id, noteable_type: 'PersonalSnippet')
end
end
@@ -320,8 +335,9 @@ describe API::Snippets do
end
describe 'PUT /snippets/:id' do
+ let_it_be(:other_user) { create(:user) }
+
let(:visibility_level) { Snippet::PUBLIC }
- let(:other_user) { create(:user) }
let(:snippet) do
create(:personal_snippet, :repository, author: user, visibility_level: visibility_level)
end
@@ -373,6 +389,20 @@ describe API::Snippets do
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'
+ end
+
it_behaves_like 'update with repository actions' do
let(:snippet_without_repo) { create(:personal_snippet, author: user, visibility_level: visibility_level) }
end
@@ -424,6 +454,32 @@ describe API::Snippets do
end
end
+ context "when admin" do
+ let(:admin) { create(:admin) }
+ let(:token) { create(:personal_access_token, user: admin, scopes: [:sudo]) }
+
+ subject do
+ put api("/snippets/#{snippet.id}", admin, personal_access_token: token), params: { visibility: 'private', sudo: user.id }
+ end
+
+ context 'when sudo is defined' do
+ it 'returns 200 and updates snippet visibility' do
+ expect(snippet.visibility).not_to eq('private')
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(json_response["visibility"]).to eq 'private'
+ end
+
+ it 'does not commit data' do
+ expect_any_instance_of(SnippetRepository).not_to receive(:multi_files_action)
+
+ subject
+ end
+ end
+ end
+
def update_snippet(snippet_id: snippet.id, params: {}, requester: user)
put api("/snippets/#{snippet_id}", requester), params: params
end
diff --git a/spec/requests/api/statistics_spec.rb b/spec/requests/api/statistics_spec.rb
index f03c1e9ca64..5aea5c225a0 100644
--- a/spec/requests/api/statistics_spec.rb
+++ b/spec/requests/api/statistics_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
describe API::Statistics, 'Statistics' do
include ProjectForksHelper
- TABLES_TO_ANALYZE = %w[
+ tables_to_analyze = %w[
projects
users
namespaces
@@ -62,7 +62,7 @@ describe API::Statistics, 'Statistics' do
# Make sure the reltuples have been updated
# to get a correct count on postgresql
- TABLES_TO_ANALYZE.each do |table|
+ tables_to_analyze.each do |table|
ActiveRecord::Base.connection.execute("ANALYZE #{table}")
end
diff --git a/spec/requests/api/terraform/state_spec.rb b/spec/requests/api/terraform/state_spec.rb
index 88c277f4e08..844cd948411 100644
--- a/spec/requests/api/terraform/state_spec.rb
+++ b/spec/requests/api/terraform/state_spec.rb
@@ -78,6 +78,14 @@ describe API::Terraform::State do
expect(response).to have_gitlab_http_status(:ok)
end
+
+ context 'on Unicorn', :unicorn do
+ it 'updates the state' do
+ expect { request }.to change { Terraform::State.count }.by(0)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
end
context 'without body' do
@@ -112,6 +120,14 @@ describe API::Terraform::State do
expect(response).to have_gitlab_http_status(:ok)
end
+
+ context 'on Unicorn', :unicorn do
+ it 'creates a new state' do
+ expect { request }.to change { Terraform::State.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
end
context 'without body' do
diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb
index 1aa5e21dddb..0bdc71a30e9 100644
--- a/spec/requests/api/todos_spec.rb
+++ b/spec/requests/api/todos_spec.rb
@@ -159,6 +159,46 @@ describe API::Todos do
expect { get api('/todos', john_doe) }.not_to exceed_query_limit(control)
expect(response).to have_gitlab_http_status(:ok)
end
+
+ context 'when there is a Design Todo' do
+ let!(:design_todo) { create_todo_for_mentioned_in_design }
+
+ def create_todo_for_mentioned_in_design
+ issue = create(:issue, project: project_1)
+ create(:todo, :mentioned,
+ user: john_doe,
+ project: project_1,
+ target: create(:design, issue: issue),
+ author: create(:user),
+ note: create(:note, project: project_1, note: "I am note, hear me roar"))
+ end
+
+ def api_request
+ get api('/todos', john_doe)
+ end
+
+ before do
+ api_request
+ end
+
+ specify do
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'avoids N+1 queries', :request_store do
+ control = ActiveRecord::QueryRecorder.new { api_request }
+
+ create_todo_for_mentioned_in_design
+
+ expect { api_request }.not_to exceed_query_limit(control)
+ end
+
+ it 'includes the Design Todo in the response' do
+ expect(json_response).to include(
+ a_hash_including('id' => design_todo.id)
+ )
+ end
+ end
end
describe 'POST /todos/:id/mark_as_done' do
@@ -235,6 +275,7 @@ describe API::Todos do
expect(json_response['state']).to eq('pending')
expect(json_response['action_name']).to eq('marked')
expect(json_response['created_at']).to be_present
+ expect(json_response['updated_at']).to be_present
end
it 'returns 304 there already exist a todo on that issuable' do
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 864f6f77f39..4a0f0eea088 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -734,7 +734,7 @@ describe API::Users, :do_not_mock_admin_mode do
end
describe "PUT /users/:id" do
- let!(:admin_user) { create(:admin) }
+ let_it_be(:admin_user) { create(:admin) }
it "returns 200 OK on success" do
put api("/users/#{user.id}", admin), params: { bio: 'new test bio' }
@@ -2405,8 +2405,8 @@ describe API::Users, :do_not_mock_admin_mode do
end
context "user activities", :clean_gitlab_redis_shared_state do
- let!(:old_active_user) { create(:user, last_activity_on: Time.utc(2000, 1, 1)) }
- let!(:newly_active_user) { create(:user, last_activity_on: 2.days.ago.midday) }
+ let_it_be(:old_active_user) { create(:user, last_activity_on: Time.utc(2000, 1, 1)) }
+ let_it_be(:newly_active_user) { create(:user, last_activity_on: 2.days.ago.midday) }
context 'last activity as normal user' do
it 'has no permission' do
diff --git a/spec/requests/api/wikis_spec.rb b/spec/requests/api/wikis_spec.rb
index 7bd9a178a8d..43a5cb446bb 100644
--- a/spec/requests/api/wikis_spec.rb
+++ b/spec/requests/api/wikis_spec.rb
@@ -25,8 +25,8 @@ describe API::Wikis do
shared_examples_for 'returns list of wiki pages' do
context 'when wiki has pages' do
let!(:pages) do
- [create(:wiki_page, wiki: project_wiki, attrs: { title: 'page1', content: 'content of page1' }),
- create(:wiki_page, wiki: project_wiki, attrs: { title: 'page2.with.dot', content: 'content of page2' })]
+ [create(:wiki_page, wiki: project_wiki, title: 'page1', content: 'content of page1'),
+ create(:wiki_page, wiki: project_wiki, title: 'page2.with.dot', content: 'content of page2')]
end
it 'returns the list of wiki pages without content' do
diff --git a/spec/requests/jwt_controller_spec.rb b/spec/requests/jwt_controller_spec.rb
index 73dc9d8c63e..d860179f0a7 100644
--- a/spec/requests/jwt_controller_spec.rb
+++ b/spec/requests/jwt_controller_spec.rb
@@ -7,11 +7,26 @@ describe JwtController do
let(:service_class) { double(new: service) }
let(:service_name) { 'test' }
let(:parameters) { { service: service_name } }
+ let(:log_output) { StringIO.new }
+ let(:logger) do
+ Logger.new(log_output).tap { |logger| logger.formatter = ->(_, _, _, msg) { msg } }
+ end
+ let(:log_data) { Gitlab::Json.parse(log_output.string) }
before do
+ Lograge.logger = logger
+
stub_const('JwtController::SERVICES', service_name => service_class)
end
+ shared_examples 'user logging' do
+ it 'logs username and ID' do
+ expect(log_data['username']).to eq(user.username)
+ expect(log_data['user_id']).to eq(user.id)
+ expect(log_data['meta.user']).to eq(user.username)
+ end
+ end
+
context 'existing service' do
subject! { get '/jwt/auth', params: parameters }
@@ -37,14 +52,17 @@ describe JwtController do
end
context 'using CI token' do
- let(:build) { create(:ci_build, :running) }
+ let(:user) { create(:user) }
+ let(:build) { create(:ci_build, :running, user: user) }
let(:project) { build.project }
let(:headers) { { authorization: credentials('gitlab-ci-token', build.token) } }
context 'project with enabled CI' do
subject! { get '/jwt/auth', params: parameters, headers: headers }
- it { expect(service_class).to have_received(:new).with(project, nil, ActionController::Parameters.new(parameters).permit!) }
+ it { expect(service_class).to have_received(:new).with(project, user, ActionController::Parameters.new(parameters).permit!) }
+
+ it_behaves_like 'user logging'
end
context 'project with disabled CI' do
@@ -57,8 +75,23 @@ describe JwtController do
it { expect(response).to have_gitlab_http_status(:unauthorized) }
end
+ context 'using deploy tokens' do
+ let(:deploy_token) { create(:deploy_token, read_registry: true, projects: [project]) }
+ let(:headers) { { authorization: credentials(deploy_token.username, deploy_token.token) } }
+
+ subject! { get '/jwt/auth', params: parameters, headers: headers }
+
+ it 'authenticates correctly' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(service_class).to have_received(:new).with(nil, deploy_token, ActionController::Parameters.new(parameters).permit!)
+ end
+
+ it 'does not log a user' do
+ expect(log_data.keys).not_to include(%w(username user_id))
+ end
+ end
+
context 'using personal access tokens' do
- let(:user) { create(:user) }
let(:pat) { create(:personal_access_token, user: user, scopes: ['read_registry']) }
let(:headers) { { authorization: credentials('personal_access_token', pat.token) } }
@@ -74,6 +107,7 @@ describe JwtController do
end
it_behaves_like 'rejecting a blocked user'
+ it_behaves_like 'user logging'
end
end
@@ -104,6 +138,8 @@ describe JwtController do
end
it { expect(service_class).to have_received(:new).with(nil, user, service_parameters) }
+
+ it_behaves_like 'user logging'
end
context 'when user has 2FA enabled' do
diff --git a/spec/requests/rack_attack_global_spec.rb b/spec/requests/rack_attack_global_spec.rb
index da0ca4c197a..175c5dd0088 100644
--- a/spec/requests/rack_attack_global_spec.rb
+++ b/spec/requests/rack_attack_global_spec.rb
@@ -262,20 +262,6 @@ describe 'Rack Attack global throttles' do
expect_rejection { post protected_path_that_does_not_require_authentication, params: post_params }
end
-
- context 'when Omnibus throttle should be used' do
- before do
- allow(Gitlab::Throttle)
- .to receive(:should_use_omnibus_protected_paths?).and_return(true)
- end
-
- it 'allows requests over the rate limit' do
- (1 + requests_per_period).times do
- post protected_path_that_does_not_require_authentication, params: post_params
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
- end
end
end
@@ -311,28 +297,6 @@ describe 'Rack Attack global throttles' do
it_behaves_like 'rate-limited token-authenticated requests'
end
-
- context 'when Omnibus throttle should be used' do
- let(:request_args) { [api(api_partial_url, personal_access_token: token)] }
- let(:other_user_request_args) { [api(api_partial_url, personal_access_token: other_user_token)] }
-
- before do
- settings_to_set[:"#{throttle_setting_prefix}_requests_per_period"] = requests_per_period
- settings_to_set[:"#{throttle_setting_prefix}_period_in_seconds"] = period_in_seconds
- settings_to_set[:"#{throttle_setting_prefix}_enabled"] = true
- stub_application_setting(settings_to_set)
-
- allow(Gitlab::Throttle)
- .to receive(:should_use_omnibus_protected_paths?).and_return(true)
- end
-
- it 'allows requests over the rate limit' do
- (1 + requests_per_period).times do
- post(*request_args)
- expect(response).not_to have_gitlab_http_status(:too_many_requests)
- end
- end
- end
end
describe 'web requests authenticated with regular login' do
@@ -352,27 +316,6 @@ describe 'Rack Attack global throttles' do
end
it_behaves_like 'rate-limited web authenticated requests'
-
- context 'when Omnibus throttle should be used' do
- before do
- settings_to_set[:"#{throttle_setting_prefix}_requests_per_period"] = requests_per_period
- settings_to_set[:"#{throttle_setting_prefix}_period_in_seconds"] = period_in_seconds
- settings_to_set[:"#{throttle_setting_prefix}_enabled"] = true
- stub_application_setting(settings_to_set)
-
- allow(Gitlab::Throttle)
- .to receive(:should_use_omnibus_protected_paths?).and_return(true)
-
- login_as(user)
- end
-
- it 'allows requests over the rate limit' do
- (1 + requests_per_period).times do
- post url_that_requires_authentication
- expect(response).not_to have_gitlab_http_status(:too_many_requests)
- end
- end
- end
end
end
end
diff --git a/spec/requests/user_activity_spec.rb b/spec/requests/user_activity_spec.rb
index 3cd4911098a..b24760d475b 100644
--- a/spec/requests/user_activity_spec.rb
+++ b/spec/requests/user_activity_spec.rb
@@ -24,8 +24,8 @@ describe 'Update of user activity' do
'/dashboard/snippets',
'/dashboard/groups',
'/dashboard/todos',
- '/group/project/issues',
- '/group/project/issues/10',
+ '/group/project/-/issues',
+ '/group/project/-/issues/10',
'/group/project/-/merge_requests',
'/group/project/-/merge_requests/15'
]
diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb
index 10cf76b607f..25216b0c712 100644
--- a/spec/routing/admin_routing_spec.rb
+++ b/spec/routing/admin_routing_spec.rb
@@ -113,13 +113,6 @@ describe Admin::HookLogsController, 'routing' do
end
end
-# admin_logs GET /admin/logs(.:format) admin/logs#show
-describe Admin::LogsController, "routing" do
- it "to #show" do
- expect(get("/admin/logs")).to route_to('admin/logs#show')
- end
-end
-
# admin_background_jobs GET /admin/background_jobs(.:format) admin/background_jobs#show
describe Admin::BackgroundJobsController, "routing" do
it "to #show" do
diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb
index fd6cccba959..f8e1ccac912 100644
--- a/spec/routing/project_routing_spec.rb
+++ b/spec/routing/project_routing_spec.rb
@@ -143,8 +143,6 @@ describe 'project routing' do
expect(get("/gitlab/gitlabhq/-/autocomplete_sources/#{action}")).to route_to("projects/autocomplete_sources##{action}", namespace_id: 'gitlab', project_id: 'gitlabhq')
end
end
-
- it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/autocomplete_sources/labels", "/gitlab/gitlabhq/-/autocomplete_sources/labels"
end
# pages_project_wikis GET /:project_id/wikis/pages(.:format) projects/wikis#pages
@@ -220,8 +218,6 @@ describe 'project routing' do
expect(delete('/gitlab/gitlabhq/-/branches/feature%2B45/foo/bar/baz')).to route_to('projects/branches#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature+45/foo/bar/baz')
expect(delete('/gitlab/gitlabhq/-/branches/feature@45/foo/bar/baz')).to route_to('projects/branches#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature@45/foo/bar/baz')
end
-
- it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/branches", "/gitlab/gitlabhq/-/branches"
end
describe Projects::TagsController, 'routing' do
@@ -234,6 +230,8 @@ describe 'project routing' do
expect(delete('/gitlab/gitlabhq/-/tags/feature%2B45/foo/bar/baz')).to route_to('projects/tags#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature+45/foo/bar/baz')
expect(delete('/gitlab/gitlabhq/-/tags/feature@45/foo/bar/baz')).to route_to('projects/tags#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature@45/foo/bar/baz')
end
+
+ it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/tags", "/gitlab/gitlabhq/-/tags"
end
# project_deploy_keys GET /:project_id/deploy_keys(.:format) deploy_keys#index
@@ -249,8 +247,6 @@ describe 'project routing' do
let(:controller) { 'deploy_keys' }
let(:controller_path) { '/-/deploy_keys' }
end
-
- it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/deploy_keys", "/gitlab/gitlabhq/-/deploy_keys"
end
# project_protected_branches GET /:project_id/protected_branches(.:format) protected_branches#index
@@ -487,7 +483,6 @@ describe 'project routing' do
let(:controller_path) { '/-/project_members' }
end
- it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/project_members", "/gitlab/gitlabhq/-/project_members"
it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/-/settings/members", "/gitlab/gitlabhq/-/project_members"
end
@@ -509,8 +504,6 @@ describe 'project routing' do
it 'to #promote' do
expect(post('/gitlab/gitlabhq/-/milestones/1/promote')).to route_to('projects/milestones#promote', namespace_id: 'gitlab', project_id: 'gitlabhq', id: "1")
end
-
- it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/milestones", "/gitlab/gitlabhq/-/milestones"
end
# project_labels GET /:project_id/labels(.:format) labels#index
@@ -518,8 +511,6 @@ describe 'project routing' do
it 'to #index' do
expect(get('/gitlab/gitlabhq/-/labels')).to route_to('projects/labels#index', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
-
- it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/labels", "/gitlab/gitlabhq/-/labels"
end
# sort_project_issues POST /:project_id/issues/sort(.:format) issues#sort
@@ -534,13 +525,17 @@ describe 'project routing' do
# DELETE /:project_id/issues/:id(.:format) issues#destroy
describe Projects::IssuesController, 'routing' do
it 'to #bulk_update' do
- expect(post('/gitlab/gitlabhq/issues/bulk_update')).to route_to('projects/issues#bulk_update', namespace_id: 'gitlab', project_id: 'gitlabhq')
+ expect(post('/gitlab/gitlabhq/-/issues/bulk_update')).to route_to('projects/issues#bulk_update', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
it_behaves_like 'RESTful project resources' do
let(:controller) { 'issues' }
let(:actions) { [:index, :create, :new, :edit, :show, :update] }
+ let(:controller_path) { '/-/issues' }
end
+
+ it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/issues", "/gitlab/gitlabhq/-/issues"
+ it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/issues/1/edit", "/gitlab/gitlabhq/-/issues/1/edit"
end
# project_noteable_notes GET /:project_id/noteable/:target_type/:target_id/notes notes#index
@@ -719,8 +714,6 @@ describe 'project routing' do
expect(get('/gitlab/gitlabhq/-/network/ends-with.json')).to route_to('projects/network#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'ends-with.json')
expect(get('/gitlab/gitlabhq/-/network/master?format=json')).to route_to('projects/network#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master', format: 'json')
end
-
- it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/network/master", "/gitlab/gitlabhq/-/network/master"
end
describe Projects::GraphsController, 'routing' do
@@ -729,8 +722,6 @@ describe 'project routing' do
expect(get('/gitlab/gitlabhq/-/graphs/ends-with.json')).to route_to('projects/graphs#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'ends-with.json')
expect(get('/gitlab/gitlabhq/-/graphs/master?format=json')).to route_to('projects/graphs#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master', format: 'json')
end
-
- it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/graphs/master", "/gitlab/gitlabhq/-/graphs/master"
end
describe Projects::ForksController, 'routing' do
@@ -741,8 +732,6 @@ describe 'project routing' do
it 'to #create' do
expect(post('/gitlab/gitlabhq/-/forks')).to route_to('projects/forks#create', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
-
- it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/forks", "/gitlab/gitlabhq/-/forks"
end
# project_avatar DELETE /project/avatar(.:format) projects/avatars#destroy
@@ -751,8 +740,6 @@ describe 'project routing' do
expect(delete('/gitlab/gitlabhq/-/avatar')).to route_to(
'projects/avatars#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
-
- it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/avatar", "/gitlab/gitlabhq/-/avatar"
end
describe Projects::PagesDomainsController, 'routing' do
@@ -798,8 +785,6 @@ describe 'project routing' do
expect(get('/gitlab/gitlabhq/-/settings/repository')).to route_to('projects/settings/repository#show', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
- it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/settings/repository", "/gitlab/gitlabhq/-/settings/repository"
-
it 'to repository#create_deploy_token' do
expect(post('gitlab/gitlabhq/-/settings/ci_cd/deploy_token/create')).to route_to('projects/settings/repository#create_deploy_token', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
@@ -835,12 +820,18 @@ describe 'project routing' do
it 'routes to usage_ping#web_ide_clientside_preview' do
expect(post('/gitlab/gitlabhq/usage_ping/web_ide_clientside_preview')).to route_to('projects/usage_ping#web_ide_clientside_preview', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
+
+ it 'routes to usage_ping#web_ide_pipelines_count' do
+ expect(post('/gitlab/gitlabhq/usage_ping/web_ide_pipelines_count')).to route_to('projects/usage_ping#web_ide_pipelines_count', namespace_id: 'gitlab', project_id: 'gitlabhq')
+ end
end
describe Projects::StaticSiteEditorController, 'routing' do
it 'routes to static_site_editor#show', :aggregate_failures do
- expect(get('/gitlab/gitlabhq/-/sse/master/CONTRIBUTING.md')).to route_to('projects/static_site_editor#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/CONTRIBUTING.md')
- expect(get('/gitlab/gitlabhq/-/sse/master/README')).to route_to('projects/static_site_editor#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/README')
+ expect(get('/gitlab/gitlabhq/-/sse/master%2FCONTRIBUTING.md')).to route_to('projects/static_site_editor#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/CONTRIBUTING.md')
+ expect(get('/gitlab/gitlabhq/-/sse/master%2FCONTRIBUTING.md/')).to route_to('projects/static_site_editor#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/CONTRIBUTING.md')
+ expect(get('/gitlab/gitlabhq/-/sse/master%2FREADME/unsupported/error')).to route_to('projects/static_site_editor#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/README', vueroute: 'unsupported/error')
+ expect(get('/gitlab/gitlabhq/-/sse/master%2Flib%2FREADME/success')).to route_to('projects/static_site_editor#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/lib/README', vueroute: 'success')
end
end
@@ -867,4 +858,20 @@ describe 'project routing' do
it_behaves_like 'redirecting a legacy project path', "/gitlab/gitlabhq/serverless", "/gitlab/gitlabhq/-/serverless"
end
end
+
+ describe Projects::DesignManagement::Designs::RawImagesController, 'routing' do
+ it 'to #show' do
+ expect(get('/gitlab/gitlabhq/-/design_management/designs/1/raw_image')).to route_to('projects/design_management/designs/raw_images#show', namespace_id: 'gitlab', project_id: 'gitlabhq', design_id: '1')
+ expect(get('/gitlab/gitlabhq/-/design_management/designs/1/c6f00aa50b80887ada30a6fe517670be9f8f9ece/raw_image')).to route_to('projects/design_management/designs/raw_images#show', namespace_id: 'gitlab', project_id: 'gitlabhq', design_id: '1', sha: 'c6f00aa50b80887ada30a6fe517670be9f8f9ece')
+ end
+ end
+
+ describe Projects::DesignManagement::Designs::ResizedImageController, 'routing' do
+ it 'to #show' do
+ expect(get('/gitlab/gitlabhq/-/design_management/designs/1/resized_image/v432x230')).to route_to('projects/design_management/designs/resized_image#show', namespace_id: 'gitlab', project_id: 'gitlabhq', design_id: '1', id: 'v432x230')
+ expect(get('/gitlab/gitlabhq/-/design_management/designs/1/c6f00aa50b80887ada30a6fe517670be9f8f9ece/resized_image/v432x230')).to route_to('projects/design_management/designs/resized_image#show', namespace_id: 'gitlab', project_id: 'gitlabhq', design_id: '1', sha: 'c6f00aa50b80887ada30a6fe517670be9f8f9ece', id: 'v432x230')
+ expect(get('/gitlab/gitlabhq/-/design_management/designs/1/invalid/resized_image/v432x230')).to route_to('application#route_not_found', unmatched_route: 'gitlab/gitlabhq/-/design_management/designs/1/invalid/resized_image/v432x230')
+ expect(get('/gitlab/gitlabhq/-/design_management/designs/1/c6f00aa50b80887ada30a6fe517670be9f8f9ece/resized_image/small')).to route_to('application#route_not_found', unmatched_route: 'gitlab/gitlabhq/-/design_management/designs/1/c6f00aa50b80887ada30a6fe517670be9f8f9ece/resized_image/small')
+ end
+ end
end
diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb
index 61599f0876f..9c3d17f7d8f 100644
--- a/spec/routing/routing_spec.rb
+++ b/spec/routing/routing_spec.rb
@@ -344,3 +344,27 @@ describe SentNotificationsController, 'routing' do
.to route_to('sent_notifications#unsubscribe', id: '4bee17d4a63ed60cf5db53417e9aeb4c')
end
end
+
+describe AutocompleteController, 'routing' do
+ it 'to #users' do
+ expect(get("/-/autocomplete/users")).to route_to('autocomplete#users')
+ end
+
+ it 'to #projects' do
+ expect(get("/-/autocomplete/projects")).to route_to('autocomplete#projects')
+ end
+
+ it 'to #award_emojis' do
+ expect(get("/-/autocomplete/award_emojis")).to route_to('autocomplete#award_emojis')
+ end
+
+ it 'to #merge_request_target_branches' do
+ expect(get("/-/autocomplete/merge_request_target_branches")).to route_to('autocomplete#merge_request_target_branches')
+ end
+
+ it 'to legacy route' do
+ expect(get("/autocomplete/users")).to route_to('autocomplete#users')
+ expect(get("/autocomplete/projects")).to route_to('autocomplete#projects')
+ expect(get("/autocomplete/award_emojis")).to route_to('autocomplete#award_emojis')
+ end
+end
diff --git a/spec/rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers_spec.rb b/spec/rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers_spec.rb
new file mode 100644
index 00000000000..11d63d8e0ee
--- /dev/null
+++ b/spec/rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers'
+
+describe RuboCop::Cop::AvoidKeywordArgumentsInSidekiqWorkers do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ it 'flags violation for keyword arguments usage in perform method signature' do
+ expect_offense(<<~RUBY)
+ def perform(id:)
+ ^^^^^^^^^^^^^^^^ Do not use keyword arguments in Sidekiq workers. For details, check https://github.com/mperham/sidekiq/issues/2372
+ end
+ RUBY
+ end
+
+ it 'flags violation for optional keyword arguments usage in perform method signature' do
+ expect_offense(<<~RUBY)
+ def perform(id: nil)
+ ^^^^^^^^^^^^^^^^^^^^ Do not use keyword arguments in Sidekiq workers. For details, check https://github.com/mperham/sidekiq/issues/2372
+ end
+ RUBY
+ end
+
+ it 'does not flag a violation for standard optional arguments usage in perform method signature' do
+ expect_no_offenses(<<~RUBY)
+ def perform(id = nil)
+ end
+ RUBY
+ end
+
+ it 'does not flag a violation for keyword arguments usage in non-perform method signatures' do
+ expect_no_offenses(<<~RUBY)
+ def helper(id:)
+ end
+ RUBY
+ end
+
+ it 'does not flag a violation for optional keyword arguments usage in non-perform method signatures' do
+ expect_no_offenses(<<~RUBY)
+ def helper(id: nil)
+ end
+ RUBY
+ end
+end
diff --git a/spec/rubocop/cop/gitlab/change_timezone_spec.rb b/spec/rubocop/cop/gitlab/change_timezone_spec.rb
new file mode 100644
index 00000000000..af76559a9fa
--- /dev/null
+++ b/spec/rubocop/cop/gitlab/change_timezone_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../../rubocop/cop/gitlab/change_timzone'
+
+describe RuboCop::Cop::Gitlab::ChangeTimezone do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'Time.zone=' do
+ it 'registers an offense with no 2nd argument' do
+ expect_offense(<<~PATTERN.strip_indent)
+ Time.zone = 'Awkland'
+ ^^^^^^^^^^^^^^^^^^^^^ Do not change timezone in the runtime (application or rspec), it could result in silently modifying other behavior.
+ PATTERN
+ end
+ end
+end
diff --git a/spec/rubocop/cop/gitlab/json_spec.rb b/spec/rubocop/cop/gitlab/json_spec.rb
new file mode 100644
index 00000000000..d64f60c8583
--- /dev/null
+++ b/spec/rubocop/cop/gitlab/json_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../../rubocop/cop/gitlab/json'
+
+describe RuboCop::Cop::Gitlab::Json do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ shared_examples('registering call offense') do |options|
+ let(:offending_lines) { options[:offending_lines] }
+
+ it 'registers an offense when the class calls JSON' do
+ inspect_source(source)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(offending_lines.size)
+ expect(cop.offenses.map(&:line)).to eq(offending_lines)
+ end
+ end
+ end
+
+ context 'when JSON is called' do
+ it_behaves_like 'registering call offense', offending_lines: [3] do
+ let(:source) do
+ <<~RUBY
+ class Foo
+ def bar
+ JSON.parse('{ "foo": "bar" }')
+ end
+ end
+ RUBY
+ end
+ end
+ end
+end
diff --git a/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb b/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb
index ce20d494542..3cb1dbbbc2c 100644
--- a/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb
+++ b/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb
@@ -159,6 +159,17 @@ describe RuboCop::Cop::InjectEnterpriseEditionModule do
SOURCE
end
+ it 'does not flag the double use of `X_if_ee` on the last line' do
+ expect_no_offenses(<<~SOURCE)
+ class Foo
+ end
+
+ Foo.extend_if_ee('EE::Foo')
+ Foo.include_if_ee('EE::Foo')
+ Foo.prepend_if_ee('EE::Foo')
+ SOURCE
+ end
+
it 'autocorrects offenses by just disabling the Cop' do
source = <<~SOURCE
class Foo
diff --git a/spec/rubocop/cop/migration/add_column_with_default_spec.rb b/spec/rubocop/cop/migration/add_column_with_default_spec.rb
index a8cf965a3ef..5d4fc59fb95 100644
--- a/spec/rubocop/cop/migration/add_column_with_default_spec.rb
+++ b/spec/rubocop/cop/migration/add_column_with_default_spec.rb
@@ -27,44 +27,15 @@ describe RuboCop::Cop::Migration::AddColumnWithDefault do
allow(cop).to receive(:in_migration?).and_return(true)
end
- let(:offense) { '`add_column_with_default` without `allow_null: true` may cause prolonged lock situations and downtime, see https://gitlab.com/gitlab-org/gitlab/issues/38060' }
+ let(:offense) { '`add_column_with_default` is deprecated, use `add_column` instead' }
- context 'for blacklisted table' do
- it 'registers an offense when specifying allow_null: false' do
- expect_offense(<<~RUBY)
- def up
- add_column_with_default(:merge_request_diff_files, :artifacts, :boolean, default: true, allow_null: false)
- ^^^^^^^^^^^^^^^^^^^^^^^ #{offense}
- end
- RUBY
- end
-
- it 'registers no offense when specifying allow_null: true' do
- expect_no_offenses(<<~RUBY)
- def up
- add_column_with_default(:merge_request_diff_files, :artifacts, :boolean, default: true, allow_null: true)
- end
- RUBY
- end
-
- it 'registers an offense when allow_null is not specified' do
- expect_offense(<<~RUBY)
- def up
- add_column_with_default(:merge_request_diff_files, :artifacts, :boolean, default: true)
- ^^^^^^^^^^^^^^^^^^^^^^^ #{offense}
- end
- RUBY
- end
- end
-
- context 'for tables not on the blacklist' do
- it 'registers no offense for application_settings (not on blacklist)' do
- expect_no_offenses(<<~RUBY)
- def up
- add_column_with_default(:application_settings, :another_column, :boolean, default: true, allow_null: false)
- end
- RUBY
- end
+ it 'registers an offense ' do
+ expect_offense(<<~RUBY)
+ def up
+ add_column_with_default(:merge_request_diff_files, :artifacts, :boolean, default: true, allow_null: false)
+ ^^^^^^^^^^^^^^^^^^^^^^^ #{offense}
+ end
+ RUBY
end
end
end
diff --git a/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb b/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb
index f0c64740e63..5b179168eab 100644
--- a/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb
+++ b/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb
@@ -42,8 +42,8 @@ describe RuboCop::Cop::Migration::AddColumnsToWideTables do
expect_offense(<<~RUBY)
def up
- add_column_with_default(:users, :another_column, :boolean, default: false)
- ^^^^^^^^^^^^^^^^^^^^^^^ #{offense}
+ add_column(:users, :another_column, :boolean, default: false)
+ ^^^^^^^^^^ #{offense}
end
RUBY
end
diff --git a/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb b/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb
index 419d74c298a..dfc3898af24 100644
--- a/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb
+++ b/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb
@@ -33,5 +33,11 @@ describe RuboCop::Cop::Migration::AddConcurrentForeignKey do
expect(cop.offenses.map(&:line)).to eq([1])
end
end
+
+ it 'does not register an offense when a `NOT VALID` foreign key is added' do
+ inspect_source('def up; add_foreign_key(:projects, :users, column: :user_id, validate: false); end')
+
+ expect(cop.offenses).to be_empty
+ end
end
end
diff --git a/spec/rubocop/cop/migration/add_limit_to_string_columns_spec.rb b/spec/rubocop/cop/migration/add_limit_to_string_columns_spec.rb
deleted file mode 100644
index 97a3ae8f2bc..00000000000
--- a/spec/rubocop/cop/migration/add_limit_to_string_columns_spec.rb
+++ /dev/null
@@ -1,268 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require 'rubocop'
-require 'rubocop/rspec/support'
-
-require_relative '../../../../rubocop/cop/migration/add_limit_to_string_columns'
-
-describe RuboCop::Cop::Migration::AddLimitToStringColumns do
- include CopHelper
-
- subject(:cop) { described_class.new }
-
- context 'in migration' do
- before do
- allow(cop).to receive(:in_migration?).and_return(true)
-
- inspect_source(migration)
- end
-
- context 'when creating a table' do
- context 'with string columns and limit' do
- let(:migration) do
- %q(
- class CreateUsers < ActiveRecord::Migration[5.2]
- DOWNTIME = false
-
- def change
- create_table :users do |t|
- t.string :username, null: false, limit: 255
- t.timestamps_with_timezone null: true
- end
- end
- end
- )
- end
-
- it 'register no offense' do
- expect(cop.offenses.size).to eq(0)
- end
-
- context 'with limit in a different position' do
- let(:migration) do
- %q(
- class CreateUsers < ActiveRecord::Migration[5.2]
- DOWNTIME = false
-
- def change
- create_table :users do |t|
- t.string :username, limit: 255, null: false
- t.timestamps_with_timezone null: true
- end
- end
- end
- )
- end
-
- it 'registers an offense' do
- expect(cop.offenses.size).to eq(0)
- end
- end
- end
-
- context 'with string columns and no limit' do
- let(:migration) do
- %q(
- class CreateUsers < ActiveRecord::Migration[5.2]
- DOWNTIME = false
-
- def change
- create_table :users do |t|
- t.string :username, null: false
- t.timestamps_with_timezone null: true
- end
- end
- end
- )
- end
-
- it 'registers an offense' do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.first.message)
- .to eq('String columns should have a limit constraint. 255 is suggested')
- end
- end
-
- context 'with no string columns' do
- let(:migration) do
- %q(
- class CreateMilestoneReleases < ActiveRecord::Migration[5.2]
- DOWNTIME = false
-
- def change
- create_table :milestone_releases do |t|
- t.integer :milestone_id
- t.integer :release_id
- end
- end
- end
- )
- end
-
- it 'register no offense' do
- expect(cop.offenses.size).to eq(0)
- end
- end
- end
-
- context 'when adding columns' do
- context 'with string columns with limit' do
- let(:migration) do
- %q(
- class AddEmailToUsers < ActiveRecord::Migration[5.2]
- DOWNTIME = false
-
- def change
- add_column :users, :email, :string, limit: 255
- end
- end
- )
- end
-
- it 'registers no offense' do
- expect(cop.offenses.size).to eq(0)
- end
-
- context 'with limit in a different position' do
- let(:migration) do
- %q(
- class AddEmailToUsers < ActiveRecord::Migration[5.2]
- DOWNTIME = false
-
- def change
- add_column :users, :email, :string, limit: 255, default: 'example@email.com'
- end
- end
- )
- end
-
- it 'registers no offense' do
- expect(cop.offenses.size).to eq(0)
- end
- end
- end
-
- context 'with string columns with no limit' do
- let(:migration) do
- %q(
- class AddEmailToUsers < ActiveRecord::Migration[5.2]
- DOWNTIME = false
-
- def change
- add_column :users, :email, :string
- end
- end
- )
- end
-
- it 'registers offense' do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.first.message)
- .to eq('String columns should have a limit constraint. 255 is suggested')
- end
- end
-
- context 'with no string columns' do
- let(:migration) do
- %q(
- class AddEmailToUsers < ActiveRecord::Migration[5.2]
- DOWNTIME = false
-
- def change
- add_column :users, :active, :boolean, default: false
- end
- end
- )
- end
-
- it 'registers no offense' do
- expect(cop.offenses.size).to eq(0)
- end
- end
- end
-
- context 'with add_column_with_default' do
- context 'with a limit' do
- let(:migration) do
- %q(
- class AddRuleTypeToApprovalMergeRequestRules < ActiveRecord::Migration[5.2]
- DOWNTIME = false
-
- def change
- add_column_with_default(:approval_merge_request_rules, :rule_type, :string, limit: 2, default: 1)
- end
- end
- )
- end
-
- it 'registers no offense' do
- expect(cop.offenses.size).to eq(0)
- end
- end
-
- context 'without a limit' do
- let(:migration) do
- %q(
- class AddRuleTypeToApprovalMergeRequestRules < ActiveRecord::Migration[5.2]
- DOWNTIME = false
-
- def change
- add_column_with_default(:approval_merge_request_rules, :rule_type, :string, default: 1)
- end
- end
- )
- end
-
- it 'registers an offense' do
- expect(cop.offenses.size).to eq(1)
- end
- end
- end
-
- context 'with methods' do
- let(:migration) do
- %q(
- class AddEmailToUsers < ActiveRecord::Migration[5.2]
- DOWNTIME = false
-
- def change
- add_column_if_table_not_exists :users, :first_name, :string, limit: 255
- search_namespace(user_name)
- end
-
- def add_column_if_not_exists(table, name, *args)
- add_column(table, name, *args) unless column_exists?(table, name)
- end
-
- def search_namespace(username)
- Uniquify.new.string(username) do |str|
- query = "SELECT id FROM namespaces WHERE parent_id IS NULL AND path='#{str}' LIMIT 1"
- connection.exec_query(query)
- end
- end
- end
- )
- end
-
- it 'registers no offense' do
- expect(cop.offenses.size).to eq(0)
- end
- end
- end
-
- context 'outside of migrations' do
- let(:active_record_model) do
- %q(
- class User < ApplicationRecord
- end
- )
- end
-
- it 'registers no offense' do
- inspect_source(active_record_model)
-
- expect(cop.offenses.size).to eq(0)
- end
- end
-end
diff --git a/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb b/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb
new file mode 100644
index 00000000000..514260a4306
--- /dev/null
+++ b/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb
@@ -0,0 +1,160 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/migration/add_limit_to_text_columns'
+
+describe RuboCop::Cop::Migration::AddLimitToTextColumns 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 text columns are defined without a limit' do
+ it 'registers an offense' do
+ expect_offense(<<~RUBY)
+ class TestTextLimits < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+ disable_ddl_transaction!
+
+ def up
+ create_table :test_text_limits, id: false do |t|
+ t.integer :test_id, null: false
+ t.text :name
+ ^^^^ #{described_class::MSG}
+ end
+
+ add_column :test_text_limits, :email, :text
+ ^^^^^^^^^^ #{described_class::MSG}
+
+ add_column_with_default :test_text_limits, :role, :text, default: 'default'
+ ^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+
+ change_column_type_concurrently :test_text_limits, :test_id, :text
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ end
+ end
+ RUBY
+
+ expect(cop.offenses.map(&:cop_name)).to all(eq('Migration/AddLimitToTextColumns'))
+ end
+ end
+
+ context 'when text columns are defined with a limit' do
+ it 'registers no offense' do
+ expect_no_offenses(<<~RUBY)
+ class TestTextLimits < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+ disable_ddl_transaction!
+
+ def up
+ create_table :test_text_limits, id: false do |t|
+ t.integer :test_id, null: false
+ t.text :name
+ end
+
+ add_column :test_text_limits, :email, :text
+ add_column_with_default :test_text_limits, :role, :text, default: 'default'
+ change_column_type_concurrently :test_text_limits, :test_id, :text
+
+ add_text_limit :test_text_limits, :name, 255
+ add_text_limit :test_text_limits, :email, 255
+ add_text_limit :test_text_limits, :role, 255
+ add_text_limit :test_text_limits, :test_id, 255
+ end
+ end
+ RUBY
+ end
+ end
+
+ # Make sure that the cop is properly checking for an `add_text_limit`
+ # over the same {table, attribute} as the one that triggered the offence
+ context 'when the limit is defined for a same name attribute but different table' do
+ it 'registers an offense' do
+ expect_offense(<<~RUBY)
+ class TestTextLimits < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+ disable_ddl_transaction!
+
+ def up
+ create_table :test_text_limits, id: false do |t|
+ t.integer :test_id, null: false
+ t.text :name
+ ^^^^ #{described_class::MSG}
+ end
+
+ add_column :test_text_limits, :email, :text
+ ^^^^^^^^^^ #{described_class::MSG}
+
+ add_column_with_default :test_text_limits, :role, :text, default: 'default'
+ ^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+
+ change_column_type_concurrently :test_text_limits, :test_id, :text
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+
+ add_text_limit :wrong_table, :name, 255
+ add_text_limit :wrong_table, :email, 255
+ add_text_limit :wrong_table, :role, 255
+ add_text_limit :wrong_table, :test_id, 255
+ end
+ end
+ RUBY
+
+ expect(cop.offenses.map(&:cop_name)).to all(eq('Migration/AddLimitToTextColumns'))
+ end
+ end
+
+ context 'on down' do
+ it 'registers no offense' do
+ expect_no_offenses(<<~RUBY)
+ class TestTextLimits < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ drop_table :no_offence_on_down
+ end
+
+ def down
+ create_table :no_offence_on_down, id: false do |t|
+ t.integer :test_id, null: false
+ t.text :name
+ end
+
+ add_column :no_offence_on_down, :email, :text
+
+ add_column_with_default :no_offence_on_down, :role, :text, default: 'default'
+ end
+ end
+ RUBY
+ end
+ end
+ end
+
+ context 'outside of migration' do
+ it 'registers no offense' do
+ expect_no_offenses(<<~RUBY)
+ class TestTextLimits < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+ disable_ddl_transaction!
+
+ def up
+ create_table :test_text_limits, id: false do |t|
+ t.integer :test_id, null: false
+ t.text :name
+ end
+
+ add_column :test_text_limits, :email, :text
+ add_column_with_default :test_text_limits, :role, :text, default: 'default'
+ change_column_type_concurrently :test_text_limits, :test_id, :text
+ end
+ end
+ RUBY
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/prevent_strings_spec.rb b/spec/rubocop/cop/migration/prevent_strings_spec.rb
new file mode 100644
index 00000000000..2702ce1c090
--- /dev/null
+++ b/spec/rubocop/cop/migration/prevent_strings_spec.rb
@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/migration/prevent_strings'
+
+describe RuboCop::Cop::Migration::PreventStrings 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 the string data type is used' do
+ it 'registers an offense' do
+ expect_offense(<<~RUBY)
+ class Users < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ create_table :users do |t|
+ t.string :username, null: false
+ ^^^^^^ #{described_class::MSG}
+
+ t.timestamps_with_timezone null: true
+
+ t.string :password
+ ^^^^^^ #{described_class::MSG}
+ end
+
+ add_column(:users, :bio, :string)
+ ^^^^^^^^^^ #{described_class::MSG}
+
+ add_column_with_default(:users, :url, :string, default: '/-/user', allow_null: false, limit: 255)
+ ^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+
+ change_column_type_concurrently :users, :commit_id, :string
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ end
+ end
+ RUBY
+
+ expect(cop.offenses.map(&:cop_name)).to all(eq('Migration/PreventStrings'))
+ end
+ end
+
+ context 'when the string data type is not used' do
+ it 'registers no offense' do
+ expect_no_offenses(<<~RUBY)
+ class Users < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ create_table :users do |t|
+ t.integer :not_a_string, null: false
+ t.timestamps_with_timezone null: true
+ end
+
+ add_column(:users, :not_a_string, :integer)
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'when the text data type is used' do
+ it 'registers no offense' do
+ expect_no_offenses(<<~RUBY)
+ class Users < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ create_table :users do |t|
+ t.text :username, null: false
+ t.timestamps_with_timezone null: true
+ t.text :password
+ end
+
+ add_column(:users, :bio, :text)
+ add_column_with_default(:users, :url, :text, default: '/-/user', allow_null: false, limit: 255)
+ change_column_type_concurrently :users, :commit_id, :text
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'on down' do
+ it 'registers no offense' do
+ expect_no_offenses(<<~RUBY)
+ class Users < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ remove_column :users, :bio
+ remove_column :users, :url
+
+ drop_table :test_strings
+ end
+
+ def down
+ create_table :test_strings, id: false do |t|
+ t.integer :test_id, null: false
+ t.string :name
+ end
+
+ add_column(:users, :bio, :string)
+ add_column_with_default(:users, :url, :string, default: '/-/user', allow_null: false, limit: 255)
+ change_column_type_concurrently :users, :commit_id, :string
+ end
+ end
+ RUBY
+ end
+ end
+ end
+
+ context 'outside of migration' do
+ it 'registers no offense' do
+ expect_no_offenses(<<~RUBY)
+ class Users < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ create_table :users do |t|
+ t.string :username, null: false
+ t.timestamps_with_timezone null: true
+ t.string :password
+ end
+
+ add_column(:users, :bio, :string)
+ add_column_with_default(:users, :url, :string, default: '/-/user', allow_null: false, limit: 255)
+ change_column_type_concurrently :users, :commit_id, :string
+ end
+ end
+ RUBY
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb b/spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb
deleted file mode 100644
index b3c5b855004..00000000000
--- a/spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb
+++ /dev/null
@@ -1,43 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-require 'rubocop'
-require 'rubocop/rspec/support'
-
-require_relative '../../../../rubocop/cop/migration/reversible_add_column_with_default'
-
-describe RuboCop::Cop::Migration::ReversibleAddColumnWithDefault do
- include CopHelper
-
- subject(:cop) { described_class.new }
-
- context 'in migration' do
- before do
- allow(cop).to receive(:in_migration?).and_return(true)
- end
-
- it 'registers an offense when add_column_with_default is used inside a change method' do
- inspect_source('def change; add_column_with_default :table, :column, default: false; end')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
- end
-
- it 'registers no offense when add_column_with_default is used inside an up method' do
- inspect_source('def up; add_column_with_default :table, :column, default: false; end')
-
- expect(cop.offenses.size).to eq(0)
- end
- end
-
- context 'outside of migration' do
- it 'registers no offense' do
- inspect_source('def change; add_column_with_default :table, :column, default: false; end')
-
- expect(cop.offenses.size).to eq(0)
- end
- end
-end
diff --git a/spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb b/spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb
new file mode 100644
index 00000000000..48570c1c8d8
--- /dev/null
+++ b/spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/migration/with_lock_retries_disallowed_method'
+
+describe RuboCop::Cop::Migration::WithLockRetriesDisallowedMethod do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'in migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(true)
+ end
+
+ it 'registers an offense when `with_lock_retries` block has disallowed method' do
+ inspect_source('def change; with_lock_retries { disallowed_method }; end')
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(1)
+ expect(cop.offenses.map(&:line)).to eq([1])
+ end
+ end
+
+ it 'registers an offense when `with_lock_retries` block has disallowed methods' do
+ source = <<~HEREDOC
+ def change
+ with_lock_retries do
+ disallowed_method
+
+ create_table do |t|
+ t.text :text
+ end
+
+ other_disallowed_method
+
+ add_column :users, :name
+ end
+ end
+ HEREDOC
+
+ inspect_source(source)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(2)
+ expect(cop.offenses.map(&:line)).to eq([3, 9])
+ end
+ end
+
+ it 'registers no offense when `with_lock_retries` has only allowed method' do
+ inspect_source('def up; with_lock_retries { add_foreign_key :foo, :bar }; end')
+
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+
+ context 'outside of migration' do
+ it 'registers no offense' do
+ inspect_source('def change; with_lock_retries { disallowed_method }; end')
+
+ expect(cop.offenses.size).to eq(0)
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/with_lock_retries_without_ddl_transaction_spec.rb b/spec/rubocop/cop/migration/with_lock_retries_without_ddl_transaction_spec.rb
deleted file mode 100644
index b42a4a14c67..00000000000
--- a/spec/rubocop/cop/migration/with_lock_retries_without_ddl_transaction_spec.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-require 'rubocop'
-require 'rubocop/rspec/support'
-
-require_relative '../../../../rubocop/cop/migration/with_lock_retries_without_ddl_transaction'
-
-describe RuboCop::Cop::Migration::WithLockRetriesWithoutDdlTransaction do
- include CopHelper
-
- let(:valid_source) { 'class MigrationClass < ActiveRecord::Migration[6.0]; def up; with_lock_retries {}; end; end' }
- let(:invalid_source) { 'class MigrationClass < ActiveRecord::Migration[6.0]; disable_ddl_transaction!; def up; with_lock_retries {}; end; end' }
-
- subject(:cop) { described_class.new }
-
- context 'in migration' do
- before do
- allow(cop).to receive(:in_migration?).and_return(true)
- end
-
- it 'registers an offense when `with_lock_retries` is used with `disable_ddl_transaction!` method' do
- inspect_source(invalid_source)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
- end
-
- it 'registers no offense when `with_lock_retries` is used inside an `up` method' do
- inspect_source(valid_source)
-
- expect(cop.offenses.size).to eq(0)
- end
- end
-
- context 'outside of migration' do
- it 'registers no offense' do
- inspect_source(invalid_source)
-
- expect(cop.offenses.size).to eq(0)
- end
- end
-end
diff --git a/spec/rubocop/cop/performance/ar_exists_and_present_blank_spec.rb b/spec/rubocop/cop/performance/ar_exists_and_present_blank_spec.rb
new file mode 100644
index 00000000000..ce4fdac56b0
--- /dev/null
+++ b/spec/rubocop/cop/performance/ar_exists_and_present_blank_spec.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require_relative '../../../support/helpers/expect_offense'
+require_relative '../../../../rubocop/cop/performance/ar_exists_and_present_blank.rb'
+
+describe RuboCop::Cop::Performance::ARExistsAndPresentBlank do
+ include CopHelper
+ include ExpectOffense
+
+ subject(:cop) { described_class.new }
+
+ context 'when it is not haml file' do
+ it 'does not flag it as an offense' do
+ expect(subject).to receive(:in_haml_file?).with(anything).at_least(:once).and_return(false)
+
+ expect_no_offenses <<~SOURCE
+ return unless @users.exists?
+ show @users if @users.present?
+ SOURCE
+ end
+ end
+
+ context 'when it is haml file' do
+ before do
+ expect(subject).to receive(:in_haml_file?).with(anything).at_least(:once).and_return(true)
+ end
+
+ context 'the same object uses exists? and present?' do
+ it 'flags it as an offense' do
+ expect_offense <<~SOURCE
+ return unless @users.exists?
+ show @users if @users.present?
+ ^^^^^^^^^^^^^^^ Avoid `@users.present?`, because it will generate database query 'Select TABLE.*' which is expensive. Suggest to use `@users.any?` to replace `@users.present?`
+ SOURCE
+
+ expect(cop.offenses.map(&:cop_name)).to contain_exactly('Performance/ARExistsAndPresentBlank')
+ end
+ end
+
+ context 'the same object uses exists? and blank?' do
+ it 'flags it as an offense' do
+ expect_offense <<~SOURCE
+ return unless @users.exists?
+ show @users if @users.blank?
+ ^^^^^^^^^^^^^ Avoid `@users.blank?`, because it will generate database query 'Select TABLE.*' which is expensive. Suggest to use `@users.empty?` to replace `@users.blank?`
+ SOURCE
+
+ expect(cop.offenses.map(&:cop_name)).to contain_exactly('Performance/ARExistsAndPresentBlank')
+ end
+ end
+
+ context 'the same object uses exists?, blank? and present?' do
+ it 'flags it as an offense' do
+ expect_offense <<~SOURCE
+ return unless @users.exists?
+ show @users if @users.blank?
+ ^^^^^^^^^^^^^ Avoid `@users.blank?`, because it will generate database query 'Select TABLE.*' which is expensive. Suggest to use `@users.empty?` to replace `@users.blank?`
+ show @users if @users.present?
+ ^^^^^^^^^^^^^^^ Avoid `@users.present?`, because it will generate database query 'Select TABLE.*' which is expensive. Suggest to use `@users.any?` to replace `@users.present?`
+ SOURCE
+
+ expect(cop.offenses.map(&:cop_name)).to contain_exactly('Performance/ARExistsAndPresentBlank', 'Performance/ARExistsAndPresentBlank')
+ end
+ end
+
+ RSpec.shared_examples 'different object uses exists? and present?/blank?' do |another_method|
+ it 'does not flag it as an offense' do
+ expect_no_offenses <<~SOURCE
+ return unless @users.exists?
+ present @emails if @emails.#{another_method}
+ SOURCE
+ end
+ end
+
+ it_behaves_like 'different object uses exists? and present?/blank?', 'present?'
+ it_behaves_like 'different object uses exists? and present?/blank?', 'blank?'
+
+ RSpec.shared_examples 'Only using one present?/blank? without exists?' do |non_exists_method|
+ it 'does not flag it as an offense' do
+ expect_no_offenses "@users.#{non_exists_method}"
+ end
+ end
+
+ it_behaves_like 'Only using one present?/blank? without exists?', 'present?'
+ it_behaves_like 'Only using one present?/blank? without exists?', 'blank?'
+
+ context 'when using many present?/empty? without exists?' do
+ it 'does not flag it as an offense' do
+ expect_no_offenses <<~SOURCE
+ @user.present?
+ @user.blank?
+ @user.present?
+ @user.blank?
+ SOURCE
+ end
+ end
+
+ context 'when just using exists? without present?/blank?' do
+ it 'does not flag it as an offense' do
+ expect_no_offenses '@users.exists?'
+
+ expect_no_offenses <<~SOURCE
+ @users.exists?
+ @users.some_other_method?
+ @users.exists?
+ SOURCE
+ end
+ end
+ end
+end
diff --git a/spec/rubocop/cop/rspec/empty_line_after_shared_example_spec.rb b/spec/rubocop/cop/rspec/empty_line_after_shared_example_spec.rb
new file mode 100644
index 00000000000..cee593fe535
--- /dev/null
+++ b/spec/rubocop/cop/rspec/empty_line_after_shared_example_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_relative '../../../../rubocop/cop/rspec/empty_line_after_shared_example'
+
+describe RuboCop::Cop::RSpec::EmptyLineAfterSharedExample do
+ subject(:cop) { described_class.new }
+
+ it 'flags a missing empty line after `it_behaves_like` block' do
+ expect_offense(<<-RUBY)
+ RSpec.describe Foo do
+ it_behaves_like 'does this' do
+ end
+ ^^^ Add an empty line after `it_behaves_like` block.
+ it_behaves_like 'does that' do
+ end
+ end
+ RUBY
+
+ expect_correction(<<-RUBY)
+ RSpec.describe Foo do
+ it_behaves_like 'does this' do
+ end
+
+ it_behaves_like 'does that' do
+ end
+ end
+ RUBY
+ end
+
+ it 'ignores one-line shared examples before shared example blocks' do
+ expect_no_offenses(<<-RUBY)
+ RSpec.describe Foo do
+ it_behaves_like 'does this'
+ it_behaves_like 'does that' do
+ end
+ end
+ RUBY
+ end
+
+ it 'flags a missing empty line after `shared_examples`' do
+ expect_offense(<<-RUBY)
+ RSpec.context 'foo' do
+ shared_examples do
+ end
+ ^^^ Add an empty line after `shared_examples` block.
+ shared_examples 'something gets done' do
+ end
+ end
+ RUBY
+
+ expect_correction(<<-RUBY)
+ RSpec.context 'foo' do
+ shared_examples do
+ end
+
+ shared_examples 'something gets done' do
+ end
+ end
+ RUBY
+ end
+
+ it 'ignores consecutive one-liners' do
+ expect_no_offenses(<<-RUBY)
+ RSpec.describe Foo do
+ it_behaves_like 'do this'
+ it_behaves_like 'do that'
+ end
+ RUBY
+ end
+
+ it 'flags mixed one-line and multi-line shared examples' do
+ expect_offense(<<-RUBY)
+ RSpec.context 'foo' do
+ it_behaves_like 'do this'
+ it_behaves_like 'do that'
+ it_behaves_like 'does this' do
+ end
+ ^^^ Add an empty line after `it_behaves_like` block.
+ it_behaves_like 'do this'
+ it_behaves_like 'do that'
+ end
+ RUBY
+ end
+end
diff --git a/spec/rubocop/cop/rspec/env_assignment_spec.rb b/spec/rubocop/cop/rspec/env_assignment_spec.rb
index 2a2bd1434d6..1c7cfb5c827 100644
--- a/spec/rubocop/cop/rspec/env_assignment_spec.rb
+++ b/spec/rubocop/cop/rspec/env_assignment_spec.rb
@@ -10,8 +10,8 @@ require_relative '../../../../rubocop/cop/rspec/env_assignment'
describe RuboCop::Cop::RSpec::EnvAssignment do
include CopHelper
- OFFENSE_CALL_SINGLE_QUOTES_KEY = %(ENV['FOO'] = 'bar').freeze
- OFFENSE_CALL_DOUBLE_QUOTES_KEY = %(ENV["FOO"] = 'bar').freeze
+ offense_call_single_quotes_key = %(ENV['FOO'] = 'bar').freeze
+ offense_call_double_quotes_key = %(ENV["FOO"] = 'bar').freeze
let(:source_file) { 'spec/foo_spec.rb' }
@@ -36,12 +36,12 @@ describe RuboCop::Cop::RSpec::EnvAssignment do
end
context 'with a key using single quotes' do
- it_behaves_like 'an offensive ENV#[]= call', OFFENSE_CALL_SINGLE_QUOTES_KEY
- it_behaves_like 'an autocorrected ENV#[]= call', OFFENSE_CALL_SINGLE_QUOTES_KEY, %(stub_env('FOO', 'bar'))
+ it_behaves_like 'an offensive ENV#[]= call', offense_call_single_quotes_key
+ it_behaves_like 'an autocorrected ENV#[]= call', offense_call_single_quotes_key, %(stub_env('FOO', 'bar'))
end
context 'with a key using double quotes' do
- it_behaves_like 'an offensive ENV#[]= call', OFFENSE_CALL_DOUBLE_QUOTES_KEY
- it_behaves_like 'an autocorrected ENV#[]= call', OFFENSE_CALL_DOUBLE_QUOTES_KEY, %(stub_env("FOO", 'bar'))
+ it_behaves_like 'an offensive ENV#[]= call', offense_call_double_quotes_key
+ it_behaves_like 'an autocorrected ENV#[]= call', offense_call_double_quotes_key, %(stub_env("FOO", 'bar'))
end
end
diff --git a/spec/serializers/accessibility_error_entity_spec.rb b/spec/serializers/accessibility_error_entity_spec.rb
new file mode 100644
index 00000000000..e9bfabb7aa8
--- /dev/null
+++ b/spec/serializers/accessibility_error_entity_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe AccessibilityErrorEntity do
+ let(:entity) { described_class.new(accessibility_error) }
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ context 'when accessibility contains an error' do
+ let(:accessibility_error) do
+ {
+ code: "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent",
+ type: "error",
+ typeCode: 1,
+ message: "Anchor element found with a valid href attribute, but no link content has been supplied.",
+ context: "<a href=\"/\" class=\"navbar-brand animated\"><svg height=\"36\" viewBox=\"0 0 1...</a>",
+ selector: "#main-nav > div:nth-child(1) > a",
+ runner: "htmlcs",
+ runnerExtras: {}
+ }
+ end
+
+ it 'contains correct accessibility error details', :aggregate_failures do
+ expect(subject[:code]).to eq("WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent")
+ expect(subject[:type]).to eq("error")
+ expect(subject[:type_code]).to eq(1)
+ expect(subject[:message]).to eq("Anchor element found with a valid href attribute, but no link content has been supplied.")
+ expect(subject[:context]).to eq("<a href=\"/\" class=\"navbar-brand animated\"><svg height=\"36\" viewBox=\"0 0 1...</a>")
+ expect(subject[:selector]).to eq("#main-nav > div:nth-child(1) > a")
+ expect(subject[:runner]).to eq("htmlcs")
+ expect(subject[:runner_extras]).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/serializers/accessibility_reports_comparer_entity_spec.rb b/spec/serializers/accessibility_reports_comparer_entity_spec.rb
new file mode 100644
index 00000000000..ed2c17de640
--- /dev/null
+++ b/spec/serializers/accessibility_reports_comparer_entity_spec.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe AccessibilityReportsComparerEntity do
+ let(:entity) { described_class.new(comparer) }
+ let(:comparer) { Gitlab::Ci::Reports::AccessibilityReportsComparer.new(base_report, head_report) }
+ let(:base_report) { Gitlab::Ci::Reports::AccessibilityReports.new }
+ let(:head_report) { Gitlab::Ci::Reports::AccessibilityReports.new }
+ let(:url) { "https://gitlab.com" }
+ let(:single_error) do
+ [
+ {
+ code: "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent",
+ type: "error",
+ typeCode: 1,
+ message: "Anchor element found with a valid href attribute, but no link content has been supplied.",
+ context: "<a href=\"/\" class=\"navbar-brand animated\"><svg height=\"36\" viewBox=\"0 0 1...</a>",
+ selector: "#main-nav > div:nth-child(1) > a",
+ runner: "htmlcs",
+ runnerExtras: {}
+ }
+ ]
+ end
+ let(:different_error) do
+ [
+ {
+ code: "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
+ type: "error",
+ typeCode: 1,
+ message: "This element has insufficient contrast at this conformance level.",
+ context: "<a href=\"/stages-devops-lifecycle/\" class=\"main-nav-link\">Product</a>",
+ selector: "#main-nav > div:nth-child(2) > ul > li:nth-child(1) > a",
+ runner: "htmlcs",
+ runnerExtras: {}
+ }
+ ]
+ end
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ context 'when base report has error and head has a different error' do
+ before do
+ base_report.add_url(url, single_error)
+ head_report.add_url(url, different_error)
+ end
+
+ it 'contains correct compared accessibility report details', :aggregate_failures do
+ expect(subject[:status]).to eq(Gitlab::Ci::Reports::AccessibilityReportsComparer::STATUS_FAILED)
+ expect(subject[:resolved_errors].first).to include(:code, :type, :type_code, :message, :context, :selector, :runner, :runner_extras)
+ expect(subject[:new_errors].first).to include(:code, :type, :type_code, :message, :context, :selector, :runner, :runner_extras)
+ expect(subject[:existing_errors].first).to include(:code, :type, :type_code, :message, :context, :selector, :runner, :runner_extras)
+ expect(subject[:summary]).to include(total: 2, resolved: 1, errored: 1)
+ end
+ end
+
+ context 'when base report has error and head has the same error' do
+ before do
+ base_report.add_url(url, single_error)
+ head_report.add_url(url, single_error)
+ end
+
+ it 'contains correct compared accessibility report details', :aggregate_failures do
+ expect(subject[:status]).to eq(Gitlab::Ci::Reports::AccessibilityReportsComparer::STATUS_FAILED)
+ expect(subject[:new_errors]).to be_empty
+ expect(subject[:resolved_errors]).to be_empty
+ expect(subject[:existing_errors].first).to include(:code, :type, :type_code, :message, :context, :selector, :runner, :runner_extras)
+ expect(subject[:summary]).to include(total: 1, resolved: 0, errored: 1)
+ end
+ end
+
+ context 'when base report has no error and head has errors' do
+ before do
+ head_report.add_url(url, single_error)
+ end
+
+ it 'contains correct compared accessibility report details', :aggregate_failures do
+ expect(subject[:status]).to eq(Gitlab::Ci::Reports::AccessibilityReportsComparer::STATUS_FAILED)
+ expect(subject[:resolved_errors]).to be_empty
+ expect(subject[:existing_errors]).to be_empty
+ expect(subject[:new_errors].first).to include(:code, :type, :type_code, :message, :context, :selector, :runner, :runner_extras)
+ expect(subject[:summary]).to include(total: 1, resolved: 0, errored: 1)
+ end
+ end
+ end
+end
diff --git a/spec/serializers/accessibility_reports_comparer_serializer_spec.rb b/spec/serializers/accessibility_reports_comparer_serializer_spec.rb
new file mode 100644
index 00000000000..37dc760fdec
--- /dev/null
+++ b/spec/serializers/accessibility_reports_comparer_serializer_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe AccessibilityReportsComparerSerializer do
+ let(:project) { double(:project) }
+ let(:serializer) { described_class.new(project: project).represent(comparer) }
+ let(:comparer) { Gitlab::Ci::Reports::AccessibilityReportsComparer.new(base_report, head_report) }
+ let(:base_report) { Gitlab::Ci::Reports::AccessibilityReports.new }
+ let(:head_report) { Gitlab::Ci::Reports::AccessibilityReports.new }
+ let(:url) { "https://gitlab.com" }
+ let(:single_error) do
+ [
+ {
+ code: "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent",
+ type: "error",
+ typeCode: 1,
+ message: "Anchor element found with a valid href attribute, but no link content has been supplied.",
+ context: "<a href=\"/\" class=\"navbar-brand animated\"><svg height=\"36\" viewBox=\"0 0 1...</a>",
+ selector: "#main-nav > divnth-child(1) > a",
+ runner: "htmlcs",
+ runnerExtras: {}
+ }
+ ]
+ end
+ let(:different_error) do
+ [
+ {
+ code: "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
+ type: "error",
+ typeCode: 1,
+ message: "This element has insufficient contrast at this conformance level.",
+ context: "<a href=\"/stages-devops-lifecycle/\" class=\"main-nav-link\">Product</a>",
+ selector: "#main-nav > divnth-child(2) > ul > linth-child(1) > a",
+ runner: "htmlcs",
+ runnerExtras: {}
+ }
+ ]
+ end
+
+ describe '#to_json' do
+ subject { serializer.as_json }
+
+ context 'when base report has error and head has a different error' do
+ before do
+ base_report.add_url(url, single_error)
+ head_report.add_url(url, different_error)
+ end
+
+ it 'matches the schema' do
+ expect(subject).to match_schema('entities/accessibility_reports_comparer')
+ end
+ end
+
+ context 'when base report has no error and head has errors' do
+ before do
+ head_report.add_url(url, single_error)
+ end
+
+ it 'matches the schema' do
+ expect(subject).to match_schema('entities/accessibility_reports_comparer')
+ end
+ end
+ end
+end
diff --git a/spec/serializers/ci/dag_job_entity_spec.rb b/spec/serializers/ci/dag_job_entity_spec.rb
new file mode 100644
index 00000000000..19b849c3879
--- /dev/null
+++ b/spec/serializers/ci/dag_job_entity_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::DagJobEntity do
+ let_it_be(:request) { double(:request) }
+
+ let(:job) { create(:ci_build, name: 'dag_job') }
+ let(:entity) { described_class.new(job, request: request) }
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ it 'contains the name' do
+ expect(subject[:name]).to eq 'dag_job'
+ end
+
+ context 'when job is stage scheduled' do
+ it 'does not expose needs' do
+ expect(subject).not_to include(:needs)
+ end
+ end
+
+ context 'when job is dag scheduled' do
+ context 'when job has needs' do
+ let(:job) { create(:ci_build, scheduling_type: 'dag') }
+ let!(:need) { create(:ci_build_need, build: job, name: 'compile') }
+
+ it 'exposes the array of needs' do
+ expect(subject[:needs]).to eq ['compile']
+ end
+ end
+
+ context 'when job has empty needs' do
+ let(:job) { create(:ci_build, scheduling_type: 'dag') }
+
+ it 'exposes an empty array of needs' do
+ expect(subject[:needs]).to eq []
+ end
+ end
+ end
+ end
+end
diff --git a/spec/serializers/ci/dag_job_group_entity_spec.rb b/spec/serializers/ci/dag_job_group_entity_spec.rb
new file mode 100644
index 00000000000..a25723894fd
--- /dev/null
+++ b/spec/serializers/ci/dag_job_group_entity_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::DagJobGroupEntity do
+ let_it_be(:request) { double(:request) }
+ let_it_be(:pipeline) { create(:ci_pipeline) }
+ let_it_be(:stage) { create(:ci_stage, pipeline: pipeline) }
+
+ let(:group) { Ci::Group.new(pipeline.project, stage, name: 'test', jobs: jobs) }
+ let(:entity) { described_class.new(group, request: request) }
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ context 'when group contains 1 job' do
+ let(:job) { create(:ci_build, stage: stage, pipeline: pipeline, name: 'test') }
+ let(:jobs) { [job] }
+
+ it 'exposes a name' do
+ expect(subject.fetch(:name)).to eq 'test'
+ end
+
+ it 'exposes the size' do
+ expect(subject.fetch(:size)).to eq 1
+ end
+
+ it 'exposes the jobs' do
+ exposed_jobs = subject.fetch(:jobs)
+
+ expect(exposed_jobs.size).to eq 1
+ expect(exposed_jobs.first.fetch(:name)).to eq 'test'
+ end
+ end
+
+ context 'when group contains multiple parallel jobs' do
+ let(:job_1) { create(:ci_build, stage: stage, pipeline: pipeline, name: 'test 1/2') }
+ let(:job_2) { create(:ci_build, stage: stage, pipeline: pipeline, name: 'test 2/2') }
+ let(:jobs) { [job_1, job_2] }
+
+ it 'exposes a name' do
+ expect(subject.fetch(:name)).to eq 'test'
+ end
+
+ it 'exposes the size' do
+ expect(subject.fetch(:size)).to eq 2
+ end
+
+ it 'exposes the jobs' do
+ exposed_jobs = subject.fetch(:jobs)
+
+ expect(exposed_jobs.size).to eq 2
+ expect(exposed_jobs.first.fetch(:name)).to eq 'test 1/2'
+ expect(exposed_jobs.last.fetch(:name)).to eq 'test 2/2'
+ end
+ end
+ end
+end
diff --git a/spec/serializers/ci/dag_pipeline_entity_spec.rb b/spec/serializers/ci/dag_pipeline_entity_spec.rb
new file mode 100644
index 00000000000..4645451e146
--- /dev/null
+++ b/spec/serializers/ci/dag_pipeline_entity_spec.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::DagPipelineEntity do
+ let_it_be(:request) { double(:request) }
+
+ let(:pipeline) { create(:ci_pipeline) }
+ let(:entity) { described_class.new(pipeline, request: request) }
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ context 'when pipeline is empty' do
+ it 'contains stages' do
+ expect(subject).to include(:stages)
+
+ expect(subject[:stages]).to be_empty
+ end
+ end
+
+ context 'when pipeline has jobs' do
+ let!(:build_job) { create(:ci_build, stage: 'build', pipeline: pipeline) }
+ let!(:test_job) { create(:ci_build, stage: 'test', pipeline: pipeline) }
+ let!(:deploy_job) { create(:ci_build, stage: 'deploy', pipeline: pipeline) }
+
+ it 'contains 3 stages' do
+ stages = subject[:stages]
+
+ expect(stages.size).to eq 3
+ expect(stages.map { |s| s[:name] }).to contain_exactly('build', 'test', 'deploy')
+ end
+ end
+
+ context 'when pipeline has parallel jobs and DAG needs' do
+ let!(:stage_build) { create(:ci_stage_entity, name: 'build', position: 1, pipeline: pipeline) }
+ let!(:stage_test) { create(:ci_stage_entity, name: 'test', position: 2, pipeline: pipeline) }
+ let!(:stage_deploy) { create(:ci_stage_entity, name: 'deploy', position: 3, pipeline: pipeline) }
+
+ let!(:job_build_1) { create(:ci_build, name: 'build 1', stage: 'build', pipeline: pipeline) }
+ let!(:job_build_2) { create(:ci_build, name: 'build 2', stage: 'build', pipeline: pipeline) }
+
+ let!(:job_rspec_1) { create(:ci_build, name: 'rspec 1/2', stage: 'test', pipeline: pipeline) }
+ let!(:job_rspec_2) { create(:ci_build, name: 'rspec 2/2', stage: 'test', pipeline: pipeline) }
+
+ let!(:job_jest) do
+ create(:ci_build, name: 'jest', stage: 'test', scheduling_type: 'dag', pipeline: pipeline).tap do |job|
+ create(:ci_build_need, name: 'build 1', build: job)
+ end
+ end
+
+ let!(:job_deploy_ruby) do
+ create(:ci_build, name: 'deploy_ruby', stage: 'deploy', scheduling_type: 'dag', pipeline: pipeline).tap do |job|
+ create(:ci_build_need, name: 'rspec 1/2', build: job)
+ create(:ci_build_need, name: 'rspec 2/2', build: job)
+ end
+ end
+
+ let!(:job_deploy_js) do
+ create(:ci_build, name: 'deploy_js', stage: 'deploy', scheduling_type: 'dag', pipeline: pipeline).tap do |job|
+ create(:ci_build_need, name: 'jest', build: job)
+ end
+ end
+
+ it 'performs the smallest number of queries' do
+ log = ActiveRecord::QueryRecorder.new { subject }
+
+ # stages, project, builds, build_needs
+ expect(log.count).to eq 4
+ end
+
+ it 'contains all the data' do
+ expected_result = {
+ stages: [
+ {
+ name: 'build',
+ groups: [
+ { name: 'build 1', size: 1, jobs: [{ name: 'build 1' }] },
+ { name: 'build 2', size: 1, jobs: [{ name: 'build 2' }] }
+ ]
+ },
+ {
+ name: 'test',
+ groups: [
+ { name: 'jest', size: 1, jobs: [{ name: 'jest', needs: ['build 1'] }] },
+ { name: 'rspec', size: 2, jobs: [{ name: 'rspec 1/2' }, { name: 'rspec 2/2' }] }
+ ]
+ },
+ {
+ name: 'deploy',
+ groups: [
+ { name: 'deploy_js', size: 1, jobs: [{ name: 'deploy_js', needs: ['jest'] }] },
+ { name: 'deploy_ruby', size: 1, jobs: [{ name: 'deploy_ruby', needs: ['rspec 1/2', 'rspec 2/2'] }] }
+ ]
+ }
+ ]
+ }
+
+ expect(subject.fetch(:stages)).not_to be_empty
+
+ expect(subject.fetch(:stages)[0].fetch(:name)).to eq 'build'
+ expect(subject.fetch(:stages)[0]).to eq expected_result.fetch(:stages)[0]
+
+ expect(subject.fetch(:stages)[1].fetch(:name)).to eq 'test'
+ expect(subject.fetch(:stages)[1]).to eq expected_result.fetch(:stages)[1]
+
+ expect(subject.fetch(:stages)[2].fetch(:name)).to eq 'deploy'
+ expect(subject.fetch(:stages)[2]).to eq expected_result.fetch(:stages)[2]
+ end
+ end
+ end
+end
diff --git a/spec/serializers/ci/dag_pipeline_serializer_spec.rb b/spec/serializers/ci/dag_pipeline_serializer_spec.rb
new file mode 100644
index 00000000000..abf895c3e77
--- /dev/null
+++ b/spec/serializers/ci/dag_pipeline_serializer_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::DagPipelineSerializer do
+ describe '#represent' do
+ subject { described_class.new.represent(pipeline) }
+
+ let(:pipeline) { create(:ci_pipeline) }
+ let!(:job) { create(:ci_build, pipeline: pipeline) }
+
+ it 'includes stages' do
+ expect(subject[:stages]).to be_present
+ expect(subject[:stages].size).to eq 1
+ end
+ end
+end
diff --git a/spec/serializers/ci/dag_stage_entity_spec.rb b/spec/serializers/ci/dag_stage_entity_spec.rb
new file mode 100644
index 00000000000..5c6aa7faee4
--- /dev/null
+++ b/spec/serializers/ci/dag_stage_entity_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::DagStageEntity do
+ let_it_be(:pipeline) { create(:ci_pipeline) }
+ let_it_be(:request) { double(:request) }
+
+ let(:stage) { build(:ci_stage, pipeline: pipeline, name: 'test') }
+ let(:entity) { described_class.new(stage, request: request) }
+
+ let!(:job) { create(:ci_build, :success, pipeline: pipeline) }
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ it 'contains valid name' do
+ expect(subject[:name]).to eq 'test'
+ end
+
+ it 'contains the job groups' do
+ expect(subject).to include :groups
+ expect(subject[:groups]).not_to be_empty
+
+ job_group = subject[:groups].first
+ expect(job_group[:name]).to eq 'test'
+ expect(job_group[:size]).to eq 1
+ expect(job_group[:jobs]).not_to be_empty
+ end
+ end
+end
diff --git a/spec/serializers/cluster_application_entity_spec.rb b/spec/serializers/cluster_application_entity_spec.rb
index 873fbf812cc..b81bdaa0d72 100644
--- a/spec/serializers/cluster_application_entity_spec.rb
+++ b/spec/serializers/cluster_application_entity_spec.rb
@@ -77,5 +77,17 @@ describe ClusterApplicationEntity do
expect(subject[:pages_domain]).to eq(id: pages_domain.id, domain: pages_domain.domain)
end
end
+
+ context 'for fluentd application' do
+ let(:application) { build(:clusters_applications_fluentd, :installed) }
+
+ it 'includes host, port, protocol and log fields' do
+ expect(subject[:port]).to eq(514)
+ expect(subject[:host]).to eq("example.com")
+ expect(subject[:protocol]).to eq("tcp")
+ expect(subject[:waf_log_enabled]).to be true
+ expect(subject[:cilium_log_enabled]).to be true
+ end
+ end
end
end
diff --git a/spec/serializers/cluster_entity_spec.rb b/spec/serializers/cluster_entity_spec.rb
index e3826a7221d..16247eef655 100644
--- a/spec/serializers/cluster_entity_spec.rb
+++ b/spec/serializers/cluster_entity_spec.rb
@@ -7,7 +7,7 @@ describe ClusterEntity do
subject { described_class.new(cluster).as_json }
context 'when provider type is gcp' do
- let(:cluster) { create(:cluster, provider_type: :gcp, provider_gcp: provider) }
+ let(:cluster) { create(:cluster, :instance, provider_type: :gcp, provider_gcp: provider) }
context 'when status is creating' do
let(:provider) { create(:cluster_provider_gcp, :creating) }
@@ -29,7 +29,7 @@ describe ClusterEntity do
end
context 'when provider type is user' do
- let(:cluster) { create(:cluster, provider_type: :user) }
+ let(:cluster) { create(:cluster, :instance, provider_type: :user) }
it 'has corresponded data' do
expect(subject[:status]).to eq(:created)
@@ -38,7 +38,7 @@ describe ClusterEntity do
end
context 'when no application has been installed' do
- let(:cluster) { create(:cluster) }
+ let(:cluster) { create(:cluster, :instance) }
subject { described_class.new(cluster).as_json[:applications]}
diff --git a/spec/serializers/cluster_serializer_spec.rb b/spec/serializers/cluster_serializer_spec.rb
index db0e65ca0fa..39551649ff0 100644
--- a/spec/serializers/cluster_serializer_spec.rb
+++ b/spec/serializers/cluster_serializer_spec.rb
@@ -3,23 +3,41 @@
require 'spec_helper'
describe ClusterSerializer do
+ let(:cluster) { create(:cluster, :project, provider_type: :user) }
+
+ describe '#represent_list' do
+ subject { described_class.new.represent_list(cluster).keys }
+
+ it 'serializes attrs correctly' do
+ is_expected.to contain_exactly(
+ :cluster_type,
+ :enabled,
+ :environment_scope,
+ :name,
+ :nodes,
+ :path,
+ :status)
+ end
+ end
+
describe '#represent_status' do
- subject { described_class.new.represent_status(cluster) }
+ subject { described_class.new.represent_status(cluster).keys }
+
+ context 'when provider type is gcp and cluster is errored' do
+ let(:cluster) do
+ errored_provider = create(:cluster_provider_gcp, :errored)
- context 'when provider type is gcp' do
- let(:cluster) { create(:cluster, provider_type: :gcp, provider_gcp: provider) }
- let(:provider) { create(:cluster_provider_gcp, :errored) }
+ create(:cluster, provider_type: :gcp, provider_gcp: errored_provider)
+ end
- it 'serializes only status' do
- expect(subject.keys).to contain_exactly(:status, :status_reason, :applications)
+ it 'serializes attrs correctly' do
+ is_expected.to contain_exactly(:status, :status_reason, :applications)
end
end
context 'when provider type is user' do
- let(:cluster) { create(:cluster, provider_type: :user) }
-
- it 'serializes only status' do
- expect(subject.keys).to contain_exactly(:status, :status_reason, :applications)
+ it 'serializes attrs correctly' do
+ is_expected.to contain_exactly(:status, :status_reason, :applications)
end
end
end
diff --git a/spec/serializers/diff_file_base_entity_spec.rb b/spec/serializers/diff_file_base_entity_spec.rb
index 80f5bc8f159..1fd697970de 100644
--- a/spec/serializers/diff_file_base_entity_spec.rb
+++ b/spec/serializers/diff_file_base_entity_spec.rb
@@ -34,4 +34,62 @@ describe DiffFileBaseEntity do
expect(entity[:new_size]).to eq(132)
end
end
+
+ context 'edit_path' do
+ let(:diff_file) { merge_request.diffs.diff_files.to_a.last }
+ let(:options) { { request: EntityRequest.new(current_user: create(:user)), merge_request: merge_request } }
+ let(:params) { {} }
+
+ before do
+ stub_feature_flags(web_ide_default: false)
+ end
+
+ shared_examples 'a diff file edit path to the source branch' do
+ it do
+ expect(entity[:edit_path]).to eq(Gitlab::Routing.url_helpers.project_edit_blob_path(project, File.join(merge_request.source_branch, diff_file.new_path), params))
+ end
+ end
+
+ context 'open' do
+ let(:merge_request) { create(:merge_request, source_project: project, target_branch: 'master', source_branch: 'feature') }
+ let(:params) { { from_merge_request_iid: merge_request.iid } }
+
+ it_behaves_like 'a diff file edit path to the source branch'
+
+ context 'removed source branch' do
+ before do
+ allow(merge_request).to receive(:source_branch_exists?).and_return(false)
+ end
+
+ it do
+ expect(entity[:edit_path]).to eq(nil)
+ end
+ end
+ end
+
+ context 'closed' do
+ let(:merge_request) { create(:merge_request, source_project: project, state: :closed, target_branch: 'master', source_branch: 'feature') }
+ let(:params) { { from_merge_request_iid: merge_request.iid } }
+
+ it_behaves_like 'a diff file edit path to the source branch'
+
+ context 'removed source branch' do
+ before do
+ allow(merge_request).to receive(:source_branch_exists?).and_return(false)
+ end
+
+ it do
+ expect(entity[:edit_path]).to eq(nil)
+ end
+ end
+ end
+
+ context 'merged' do
+ let(:merge_request) { create(:merge_request, source_project: project, state: :merged) }
+
+ it do
+ expect(entity[:edit_path]).to eq(Gitlab::Routing.url_helpers.project_edit_blob_path(project, File.join(merge_request.target_branch, diff_file.new_path), {}))
+ end
+ end
+ end
end
diff --git a/spec/serializers/diffs_metadata_entity_spec.rb b/spec/serializers/diffs_metadata_entity_spec.rb
index a6bf9a7700e..3ed2b7c9452 100644
--- a/spec/serializers/diffs_metadata_entity_spec.rb
+++ b/spec/serializers/diffs_metadata_entity_spec.rb
@@ -29,7 +29,7 @@ describe DiffsMetadataEntity do
:added_lines, :removed_lines, :render_overflow_warning,
:email_patch_path, :plain_diff_path,
:merge_request_diffs, :context_commits,
- :definition_path_prefix,
+ :definition_path_prefix, :source_branch_exists,
# Attributes
:diff_files
)
diff --git a/spec/serializers/environment_entity_spec.rb b/spec/serializers/environment_entity_spec.rb
index b4ea90d2141..36e971c467a 100644
--- a/spec/serializers/environment_entity_spec.rb
+++ b/spec/serializers/environment_entity_spec.rb
@@ -10,7 +10,13 @@ describe EnvironmentEntity do
described_class.new(environment, request: spy('request'))
end
- let(:environment) { create(:environment) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:environment) { create(:environment, project: project) }
+
+ before do
+ allow(entity).to receive(:current_user).and_return(user)
+ end
subject { entity.as_json }
@@ -67,28 +73,48 @@ describe EnvironmentEntity do
end
context 'with auto_stop_in' do
- let(:environment) { create(:environment, :will_auto_stop) }
+ let(:environment) { create(:environment, :will_auto_stop, project: project) }
it 'exposes auto stop related information' do
+ project.add_maintainer(user)
+
expect(subject).to include(:cancel_auto_stop_path, :auto_stop_at)
end
end
context 'pod_logs' do
- it 'exposes logs keys' do
- expect(subject).to include(:logs_path)
- expect(subject).to include(:logs_api_path)
- expect(subject).to include(:enable_advanced_logs_querying)
- end
+ context 'with developer access' do
+ before do
+ project.add_developer(user)
+ end
- it 'uses k8s api when ES is not available' do
- expect(subject[:logs_api_path]).to eq(k8s_project_logs_path(environment.project, environment_name: environment.name, format: :json))
+ it 'does not expose logs keys' do
+ expect(subject).not_to include(:logs_path)
+ expect(subject).not_to include(:logs_api_path)
+ expect(subject).not_to include(:enable_advanced_logs_querying)
+ end
end
- it 'uses ES api when ES is available' do
- allow(environment).to receive(:elastic_stack_available?).and_return(true)
+ context 'with maintainer access' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'exposes logs keys' do
+ expect(subject).to include(:logs_path)
+ expect(subject).to include(:logs_api_path)
+ expect(subject).to include(:enable_advanced_logs_querying)
+ end
- expect(subject[:logs_api_path]).to eq(elasticsearch_project_logs_path(environment.project, environment_name: environment.name, format: :json))
+ it 'uses k8s api when ES is not available' do
+ expect(subject[:logs_api_path]).to eq(k8s_project_logs_path(project, environment_name: environment.name, format: :json))
+ end
+
+ it 'uses ES api when ES is available' do
+ allow(environment).to receive(:elastic_stack_available?).and_return(true)
+
+ expect(subject[:logs_api_path]).to eq(elasticsearch_project_logs_path(project, environment_name: environment.name, format: :json))
+ 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 fe0b717ede0..4b3bfc99c88 100644
--- a/spec/serializers/merge_request_poll_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_poll_widget_entity_spec.rb
@@ -71,6 +71,50 @@ describe MergeRequestPollWidgetEntity do
end
end
+ describe 'terraform_reports_path' do
+ context 'when merge request has terraform reports' do
+ before do
+ allow(resource).to receive(:has_terraform_reports?).and_return(true)
+ end
+
+ it 'set the path to poll data' do
+ expect(subject[:terraform_reports_path]).to be_present
+ end
+ end
+
+ context 'when merge request has no terraform reports' do
+ before do
+ allow(resource).to receive(:has_terraform_reports?).and_return(false)
+ end
+
+ it 'set the path to poll data' do
+ expect(subject[:terraform_reports_path]).to be_nil
+ end
+ end
+ end
+
+ describe 'accessibility_report_path' do
+ context 'when merge request has accessibility reports' do
+ before do
+ allow(resource).to receive(:has_accessibility_reports?).and_return(true)
+ end
+
+ it 'set the path to poll data' do
+ expect(subject[:accessibility_report_path]).to be_present
+ end
+ end
+
+ context 'when merge request has no accessibility reports' do
+ before do
+ allow(resource).to receive(:has_accessibility_reports?).and_return(false)
+ end
+
+ it 'set the path to poll data' do
+ expect(subject[:accessibility_report_path]).to be_nil
+ end
+ end
+ end
+
describe 'exposed_artifacts_path' do
context 'when merge request has exposed artifacts' do
before do
diff --git a/spec/serializers/merge_request_sidebar_basic_entity_spec.rb b/spec/serializers/merge_request_sidebar_basic_entity_spec.rb
index b364b1a3306..b2db57801ea 100644
--- a/spec/serializers/merge_request_sidebar_basic_entity_spec.rb
+++ b/spec/serializers/merge_request_sidebar_basic_entity_spec.rb
@@ -13,7 +13,7 @@ describe MergeRequestSidebarBasicEntity do
describe '#current_user' do
it 'contains attributes related to the current user' do
- expect(entity[:current_user].keys).to contain_exactly(
+ expect(entity[:current_user].keys).to include(
:id, :name, :username, :state, :avatar_url, :web_url, :todo,
:can_edit, :can_move, :can_admin_label, :can_merge
)
diff --git a/spec/serializers/service_event_entity_spec.rb b/spec/serializers/service_event_entity_spec.rb
new file mode 100644
index 00000000000..fc11263807b
--- /dev/null
+++ b/spec/serializers/service_event_entity_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ServiceEventEntity do
+ let(:request) { double('request') }
+
+ subject { described_class.new(event, request: request, service: service).as_json }
+
+ before do
+ allow(request).to receive(:service).and_return(service)
+ end
+
+ describe '#as_json' do
+ context 'service without fields' do
+ let(:service) { create(:emails_on_push_service, push_events: true) }
+ let(:event) { 'push' }
+
+ it 'exposes correct attributes' do
+ expect(subject[:description]).to eq('Event will be triggered by a push to the repository')
+ expect(subject[:name]).to eq('push_events')
+ expect(subject[:title]).to eq('push')
+ expect(subject[:value]).to be(true)
+ end
+ end
+
+ context 'service with fields' do
+ let(:service) { create(:slack_service, note_events: false, note_channel: 'note-channel') }
+ let(:event) { 'note' }
+
+ it 'exposes correct attributes' do
+ expect(subject[:description]).to eq('Event will be triggered when someone adds a comment')
+ expect(subject[:name]).to eq('note_events')
+ expect(subject[:title]).to eq('note')
+ expect(subject[:value]).to eq(false)
+ expect(subject[:field][:name]).to eq('note_channel')
+ expect(subject[:field][:value]).to eq('note-channel')
+ end
+ end
+ end
+end
diff --git a/spec/serializers/test_case_entity_spec.rb b/spec/serializers/test_case_entity_spec.rb
index f16c271be4d..9f1822ff581 100644
--- a/spec/serializers/test_case_entity_spec.rb
+++ b/spec/serializers/test_case_entity_spec.rb
@@ -38,7 +38,7 @@ describe TestCaseEntity do
end
context 'when attachment is present' do
- let(:test_case) { build(:test_case, :with_attachment) }
+ let(:test_case) { build(:test_case, :failed_with_attachment) }
it 'returns the attachment_url' do
expect(subject).to include(:attachment_url)
@@ -60,7 +60,7 @@ describe TestCaseEntity do
end
context 'when attachment is present' do
- let(:test_case) { build(:test_case, :with_attachment) }
+ let(:test_case) { build(:test_case, :failed_with_attachment) }
it 'returns no attachment_url' do
expect(subject).not_to include(:attachment_url)
diff --git a/spec/serializers/test_suite_entity_spec.rb b/spec/serializers/test_suite_entity_spec.rb
index 6a9653954f3..bd88d235013 100644
--- a/spec/serializers/test_suite_entity_spec.rb
+++ b/spec/serializers/test_suite_entity_spec.rb
@@ -3,27 +3,65 @@
require 'spec_helper'
describe TestSuiteEntity do
- let(:pipeline) { create(:ci_pipeline, :with_test_reports) }
- let(:entity) { described_class.new(pipeline.test_reports.test_suites.each_value.first) }
+ let(:pipeline) { create(:ci_pipeline, :with_test_reports) }
+ let(:test_suite) { pipeline.test_reports.test_suites.each_value.first }
+ let(:entity) { described_class.new(test_suite) }
describe '#as_json' do
subject(:as_json) { entity.as_json }
it 'contains the suite name' do
- expect(as_json).to include(:name)
+ expect(as_json[:name]).to be_present
end
it 'contains the total time' do
- expect(as_json).to include(:total_time)
+ expect(as_json[:total_time]).to be_present
end
it 'contains the counts' do
- expect(as_json).to include(:total_count, :success_count, :failed_count, :skipped_count, :error_count)
+ expect(as_json[:total_count]).to eq(4)
+ expect(as_json[:success_count]).to eq(2)
+ expect(as_json[:failed_count]).to eq(2)
+ expect(as_json[:skipped_count]).to eq(0)
+ expect(as_json[:error_count]).to eq(0)
end
it 'contains the test cases' do
- expect(as_json).to include(:test_cases)
expect(as_json[:test_cases].count).to eq(4)
end
+
+ it 'contains an empty error message' do
+ expect(as_json[:suite_error]).to be_nil
+ end
+
+ context 'with a suite error' do
+ before do
+ test_suite.set_suite_error('a really bad error')
+ end
+
+ it 'contains the suite name' do
+ expect(as_json[:name]).to be_present
+ end
+
+ it 'contains the total time' do
+ expect(as_json[:total_time]).to be_present
+ end
+
+ it 'returns all the counts as 0' do
+ expect(as_json[:total_count]).to eq(0)
+ expect(as_json[:success_count]).to eq(0)
+ expect(as_json[:failed_count]).to eq(0)
+ expect(as_json[:skipped_count]).to eq(0)
+ expect(as_json[:error_count]).to eq(0)
+ end
+
+ it 'returns no test cases' do
+ expect(as_json[:test_cases]).to be_empty
+ end
+
+ it 'returns a suite error' do
+ expect(as_json[:suite_error]).to eq('a really bad error')
+ end
+ end
end
end
diff --git a/spec/services/alert_management/create_alert_issue_service_spec.rb b/spec/services/alert_management/create_alert_issue_service_spec.rb
new file mode 100644
index 00000000000..62afe777165
--- /dev/null
+++ b/spec/services/alert_management/create_alert_issue_service_spec.rb
@@ -0,0 +1,152 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AlertManagement::CreateAlertIssueService do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:payload) do
+ {
+ 'annotations' => {
+ 'title' => 'Alert title'
+ },
+ 'startsAt' => '2020-04-27T10:10:22.265949279Z',
+ 'generatorURL' => 'http://8d467bd4607a:9090/graph?g0.expr=vector%281%29&g0.tab=1'
+ }
+ end
+ let_it_be(:generic_alert, reload: true) { create(:alert_management_alert, :triggered, project: project, payload: payload) }
+ let_it_be(:prometheus_alert) { create(:alert_management_alert, :triggered, :prometheus, project: project, payload: payload) }
+ let(:alert) { generic_alert }
+ let(:created_issue) { Issue.last! }
+
+ describe '#execute' do
+ subject(:execute) { described_class.new(alert, user).execute }
+
+ before do
+ allow(user).to receive(:can?).and_call_original
+ allow(user).to receive(:can?)
+ .with(:create_issue, project)
+ .and_return(can_create)
+ end
+
+ shared_examples 'creating an alert' do
+ it 'creates an issue' do
+ expect { execute }.to change { project.issues.count }.by(1)
+ end
+
+ it 'returns a created issue' do
+ expect(execute.payload).to eq(issue: created_issue)
+ end
+
+ it 'has a successful status' do
+ expect(execute).to be_success
+ end
+
+ it 'updates alert.issue_id' do
+ execute
+
+ expect(alert.reload.issue_id).to eq(created_issue.id)
+ end
+
+ it 'sets issue author to the current user' do
+ execute
+
+ expect(created_issue.author).to eq(user)
+ end
+ end
+
+ context 'when a user is allowed to create an issue' do
+ let(:can_create) { true }
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'checks permissions' do
+ execute
+ expect(user).to have_received(:can?).with(:create_issue, project)
+ end
+
+ context 'when the alert is prometheus alert' do
+ let(:alert) { prometheus_alert }
+
+ it_behaves_like 'creating an alert'
+ end
+
+ context 'when the alert is generic' do
+ let(:alert) { generic_alert }
+
+ it_behaves_like 'creating an alert'
+ end
+
+ context 'when issue cannot be created' do
+ let(:alert) { prometheus_alert }
+
+ before do
+ # set invalid payload for Prometheus alert
+ alert.update!(payload: {})
+ end
+
+ it 'has an unsuccessful status' do
+ expect(execute).to be_error
+ expect(execute.message).to eq('invalid alert')
+ end
+ end
+
+ context 'when alert cannot be updated' do
+ before do
+ # invalidate alert
+ too_many_hosts = Array.new(AlertManagement::Alert::HOSTS_MAX_LENGTH + 1) { |_| 'host' }
+ alert.update_columns(hosts: too_many_hosts)
+ end
+
+ it 'responds with error' do
+ expect(execute).to be_error
+ expect(execute.message).to eq('Hosts hosts array is over 255 chars')
+ end
+ end
+
+ context 'when alert already has an attached issue' do
+ let!(:issue) { create(:issue, project: project) }
+
+ before do
+ alert.update!(issue_id: issue.id)
+ end
+
+ it 'does not create yet another issue' do
+ expect { execute }.not_to change(Issue, :count)
+ end
+
+ it 'responds with error' do
+ expect(execute).to be_error
+ expect(execute.message).to eq(_('An issue already exists'))
+ end
+ end
+
+ context 'when alert_management_create_alert_issue feature flag is disabled' do
+ before do
+ stub_feature_flags(alert_management_create_alert_issue: false)
+ end
+
+ it 'responds with error' do
+ expect(execute).to be_error
+ expect(execute.message).to eq(_('You have no permissions'))
+ end
+ end
+ end
+
+ context 'when a user is not allowed to create an issue' do
+ let(:can_create) { false }
+
+ it 'checks permissions' do
+ execute
+ expect(user).to have_received(:can?).with(:create_issue, project)
+ end
+
+ it 'responds with error' do
+ expect(execute).to be_error
+ expect(execute.message).to eq(_('You have no permissions'))
+ end
+ end
+ end
+end
diff --git a/spec/services/alert_management/process_prometheus_alert_service_spec.rb b/spec/services/alert_management/process_prometheus_alert_service_spec.rb
new file mode 100644
index 00000000000..73f9f103902
--- /dev/null
+++ b/spec/services/alert_management/process_prometheus_alert_service_spec.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AlertManagement::ProcessPrometheusAlertService do
+ let_it_be(:project) { create(:project) }
+
+ describe '#execute' do
+ subject { described_class.new(project, nil, payload).execute }
+
+ context 'when alert payload is valid' do
+ let(:parsed_alert) { Gitlab::Alerting::Alert.new(project: project, payload: payload) }
+ let(:payload) do
+ {
+ 'status' => status,
+ 'labels' => {
+ 'alertname' => 'GitalyFileServerDown',
+ 'channel' => 'gitaly',
+ 'pager' => 'pagerduty',
+ 'severity' => 's1'
+ },
+ 'annotations' => {
+ 'description' => 'Alert description',
+ 'runbook' => 'troubleshooting/gitaly-down.md',
+ 'title' => 'Alert title'
+ },
+ 'startsAt' => '2020-04-27T10:10:22.265949279Z',
+ 'endsAt' => '2020-04-27T10:20:22.265949279Z',
+ 'generatorURL' => 'http://8d467bd4607a:9090/graph?g0.expr=vector%281%29&g0.tab=1',
+ 'fingerprint' => 'b6ac4d42057c43c1'
+ }
+ end
+
+ context 'when Prometheus alert status is firing' do
+ let(:status) { 'firing' }
+
+ context 'when alert with the same fingerprint already exists' do
+ let!(:alert) { create(:alert_management_alert, :resolved, project: project, fingerprint: parsed_alert.gitlab_fingerprint) }
+
+ context 'when status can be changed' do
+ it 'changes status to triggered' do
+ expect { subject }.to change { alert.reload.triggered? }.to(true)
+ 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(:trigger).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 triggered',
+ project_id: project.id,
+ alert_id: alert.id
+ )
+
+ subject
+ end
+ end
+
+ it { is_expected.to be_success }
+ end
+
+ context 'when alert does not exist' do
+ context 'when alert can be created' do
+ it 'creates a new alert' do
+ expect { subject }.to change { AlertManagement::Alert.where(project: project).count }.by(1)
+ 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)
+ 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'] }
+ )
+
+ subject
+ end
+ end
+
+ it { is_expected.to be_success }
+ end
+ end
+
+ context 'when Prometheus alert status is resolved' do
+ let(:status) { 'resolved' }
+ let!(:alert) { create(:alert_management_alert, project: project, fingerprint: parsed_alert.gitlab_fingerprint) }
+
+ context 'when status can be changed' do
+ it 'resolves an existing alert' do
+ expect { subject }.to change { alert.reload.resolved? }.to(true)
+ 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
+
+ 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
+ )
+
+ subject
+ end
+ end
+
+ it { is_expected.to be_success }
+ end
+ end
+
+ context 'when alert payload is invalid' do
+ let(:payload) { {} }
+
+ it 'responds with bad_request' do
+ expect(subject).to be_error
+ expect(subject.http_status).to eq(:bad_request)
+ end
+ end
+ end
+end
diff --git a/spec/services/alert_management/update_alert_status_service_spec.rb b/spec/services/alert_management/update_alert_status_service_spec.rb
new file mode 100644
index 00000000000..b287d0d1614
--- /dev/null
+++ b/spec/services/alert_management/update_alert_status_service_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe AlertManagement::UpdateAlertStatusService do
+ let(:project) { alert.project }
+ let_it_be(:user) { build(:user) }
+
+ let_it_be(:alert, reload: true) do
+ create(:alert_management_alert, :triggered)
+ end
+
+ let(:service) { described_class.new(alert, user, new_status) }
+
+ describe '#execute' do
+ shared_examples 'update failure' do |error_message|
+ it 'returns an error' do
+ expect(response).to be_error
+ expect(response.message).to eq(error_message)
+ expect(response.payload[:alert]).to eq(alert)
+ end
+
+ it 'does not update the status' do
+ expect { response }.not_to change { alert.status }
+ end
+ end
+
+ let(:new_status) { Types::AlertManagement::StatusEnum.values['ACKNOWLEDGED'].value }
+ let(:can_update) { true }
+
+ subject(:response) { service.execute }
+
+ before do
+ allow(user).to receive(:can?)
+ .with(:update_alert_management_alert, project)
+ .and_return(can_update)
+ end
+
+ it 'returns success' do
+ expect(response).to be_success
+ expect(response.payload[:alert]).to eq(alert)
+ end
+
+ it 'updates the status' do
+ expect { response }.to change { alert.acknowledged? }.to(true)
+ end
+
+ context 'when user has no permissions' do
+ let(:can_update) { false }
+
+ include_examples 'update failure', _('You have no permissions')
+ end
+
+ context 'with no status' do
+ let(:new_status) { nil }
+
+ include_examples 'update failure', _('Invalid status')
+ end
+
+ context 'with unknown status' do
+ let(:new_status) { -1 }
+
+ include_examples 'update failure', _('Invalid status')
+ end
+ end
+end
diff --git a/spec/services/application_settings/update_service_spec.rb b/spec/services/application_settings/update_service_spec.rb
index 069572e4dff..3a37cbc3522 100644
--- a/spec/services/application_settings/update_service_spec.rb
+++ b/spec/services/application_settings/update_service_spec.rb
@@ -335,7 +335,7 @@ describe ApplicationSettings::UpdateService do
end
end
- context 'when issues_create_limit is passsed' do
+ context 'when issues_create_limit is passed' do
let(:params) do
{
issues_create_limit: 600
diff --git a/spec/services/auth/container_registry_authentication_service_spec.rb b/spec/services/auth/container_registry_authentication_service_spec.rb
index 8273269c2fb..70eb35f0826 100644
--- a/spec/services/auth/container_registry_authentication_service_spec.rb
+++ b/spec/services/auth/container_registry_authentication_service_spec.rb
@@ -35,11 +35,11 @@ describe Auth::ContainerRegistryAuthenticationService do
it { expect(payload).to include('access') }
context 'a expirable' do
- let(:expires_at) { Time.at(payload['exp']) }
+ let(:expires_at) { Time.zone.at(payload['exp']) }
let(:expire_delay) { 10 }
context 'for default configuration' do
- it { expect(expires_at).not_to be_within(2.seconds).of(Time.now + expire_delay.minutes) }
+ it { expect(expires_at).not_to be_within(2.seconds).of(Time.current + expire_delay.minutes) }
end
context 'for changed configuration' do
@@ -47,7 +47,7 @@ describe Auth::ContainerRegistryAuthenticationService do
stub_application_setting(container_registry_token_expire_delay: expire_delay)
end
- it { expect(expires_at).to be_within(2.seconds).of(Time.now + expire_delay.minutes) }
+ it { expect(expires_at).to be_within(2.seconds).of(Time.current + expire_delay.minutes) }
end
end
end
@@ -205,6 +205,20 @@ describe Auth::ContainerRegistryAuthenticationService do
it_behaves_like 'an inaccessible'
it_behaves_like 'not a container repository factory'
+
+ it 'logs an auth warning' do
+ expect(Gitlab::AuthLogger).to receive(:warn).with(
+ message: 'Denied container registry permissions',
+ scope_type: 'repository',
+ requested_project_path: project.full_path,
+ requested_actions: ['*'],
+ authorized_actions: [],
+ user_id: current_user.id,
+ username: current_user.username
+ )
+
+ subject
+ end
end
context 'disallow developer to delete images since registry 2.7' 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
new file mode 100644
index 00000000000..49ea538d909
--- /dev/null
+++ b/spec/services/authorized_project_update/project_create_service_spec.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe AuthorizedProjectUpdate::ProjectCreateService do
+ let_it_be(:group_parent) { create(:group, :private) }
+ let_it_be(:group) { create(:group, :private, parent: group_parent) }
+ let_it_be(:group_child) { create(:group, :private, parent: group) }
+
+ let_it_be(:group_project) { create(:project, group: group) }
+
+ let_it_be(:parent_group_user) { create(:user) }
+ let_it_be(:group_user) { create(:user) }
+ let_it_be(:child_group_user) { create(:user) }
+
+ let(:access_level) { Gitlab::Access::MAINTAINER }
+
+ subject(:service) { described_class.new(group_project) }
+
+ describe '#perform' do
+ context 'direct group members' do
+ before do
+ create(:group_member, access_level: access_level, group: group, user: group_user)
+ ProjectAuthorization.delete_all
+ end
+
+ it 'creates project authorization' do
+ expect { service.execute }.to(
+ change { ProjectAuthorization.count }.from(0).to(1))
+
+ project_authorization = ProjectAuthorization.where(
+ project_id: group_project.id,
+ user_id: group_user.id,
+ access_level: access_level)
+
+ expect(project_authorization).to exist
+ end
+ end
+
+ context 'inherited group members' do
+ before do
+ create(:group_member, access_level: access_level, group: group_parent, user: parent_group_user)
+ ProjectAuthorization.delete_all
+ end
+
+ it 'creates project authorization' do
+ expect { service.execute }.to(
+ change { ProjectAuthorization.count }.from(0).to(1))
+
+ project_authorization = ProjectAuthorization.where(
+ project_id: group_project.id,
+ user_id: parent_group_user.id,
+ access_level: access_level)
+ expect(project_authorization).to exist
+ end
+ end
+
+ context 'membership overrides' do
+ before do
+ create(:group_member, access_level: Gitlab::Access::REPORTER, group: group_parent, user: group_user)
+ create(:group_member, access_level: Gitlab::Access::DEVELOPER, group: group, user: group_user)
+ ProjectAuthorization.delete_all
+ end
+
+ it 'creates project authorization' do
+ expect { service.execute }.to(
+ change { ProjectAuthorization.count }.from(0).to(1))
+
+ project_authorization = ProjectAuthorization.where(
+ project_id: group_project.id,
+ user_id: group_user.id,
+ access_level: Gitlab::Access::DEVELOPER)
+ expect(project_authorization).to exist
+ end
+ end
+
+ context 'no group member' do
+ it 'does not create project authorization' do
+ expect { service.execute }.not_to(
+ change { ProjectAuthorization.count }.from(0))
+ end
+ end
+
+ context 'unapproved access requests' do
+ before do
+ create(:group_member, :guest, :access_request, user: group_user, group: 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 ) }
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", batch_size)
+
+ users.each do |user|
+ create(:group_member, access_level: access_level, group: group_parent, user: user)
+ end
+
+ ProjectAuthorization.delete_all
+ end
+
+ it 'bulk creates project authorizations in batches' do
+ users.each_slice(batch_size) do |batch|
+ attributes = batch.map do |user|
+ { user_id: user.id, project_id: group_project.id, access_level: access_level }
+ end
+
+ expect(ProjectAuthorization).to(
+ receive(:insert_all).with(array_including(attributes)).and_call_original)
+ end
+
+ expect { service.execute }.to(
+ change { ProjectAuthorization.count }.from(0).to(batch_size + 1))
+ end
+ end
+
+ context 'ignores existing project authorizations' do
+ before do
+ # ProjectAuthorizations is also created because of an after_commit
+ # callback on Member model
+ create(:group_member, access_level: access_level, group: group, user: group_user)
+ end
+
+ it 'does not create project authorization' do
+ project_authorization = ProjectAuthorization.where(
+ project_id: group_project.id,
+ user_id: group_user.id,
+ access_level: access_level)
+
+ expect { service.execute }.not_to(
+ change { project_authorization.reload.exists? }.from(true))
+ end
+ end
+ end
+end
diff --git a/spec/services/base_container_service_spec.rb b/spec/services/base_container_service_spec.rb
new file mode 100644
index 00000000000..47cfb387e25
--- /dev/null
+++ b/spec/services/base_container_service_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe BaseContainerService do
+ let(:project) { Project.new }
+ let(:user) { User.new }
+
+ describe '#initialize' do
+ it 'accepts container and current_user' do
+ subject = described_class.new(container: project, current_user: user)
+
+ expect(subject.container).to eq(project)
+ expect(subject.current_user).to eq(user)
+ end
+
+ it 'treats current_user as optional' do
+ subject = described_class.new(container: project)
+
+ expect(subject.current_user).to be_nil
+ end
+ end
+end
diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb
index 33538703e92..c46ab004af6 100644
--- a/spec/services/boards/issues/list_service_spec.rb
+++ b/spec/services/boards/issues/list_service_spec.rb
@@ -87,7 +87,7 @@ describe Boards::Issues::ListService do
let!(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, title: 'Issue 1', labels: [bug]) }
let!(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, title: 'Issue 2', labels: [p2, p2_project]) }
let!(:opened_issue3) { create(:labeled_issue, project: project_archived, milestone: m1, title: 'Issue 3', labels: [bug]) }
- let!(:reopened_issue1) { create(:issue, state: 'opened', project: project, title: 'Reopened Issue 1', closed_at: Time.now ) }
+ let!(:reopened_issue1) { create(:issue, state: 'opened', project: project, title: 'Reopened Issue 1', closed_at: Time.current ) }
let!(:list1_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [p2, p2_project, development]) }
let!(:list1_issue2) { create(:labeled_issue, project: project, milestone: m2, labels: [development]) }
diff --git a/spec/services/branches/create_service_spec.rb b/spec/services/branches/create_service_spec.rb
index b0629c5e25a..072a86d17fc 100644
--- a/spec/services/branches/create_service_spec.rb
+++ b/spec/services/branches/create_service_spec.rb
@@ -3,39 +3,45 @@
require 'spec_helper'
describe Branches::CreateService do
- let(:user) { create(:user) }
-
subject(:service) { described_class.new(project, user) }
+ let_it_be(:project) { create(:project_empty_repo) }
+ let_it_be(:user) { create(:user) }
+
describe '#execute' do
context 'when repository is empty' do
- let(:project) { create(:project_empty_repo) }
-
it 'creates master branch' do
service.execute('my-feature', 'master')
expect(project.repository.branch_exists?('master')).to be_truthy
end
- it 'creates my-feature branch' do
- service.execute('my-feature', 'master')
+ it 'creates another-feature branch' do
+ service.execute('another-feature', 'master')
- expect(project.repository.branch_exists?('my-feature')).to be_truthy
+ expect(project.repository.branch_exists?('another-feature')).to be_truthy
end
end
- context 'when creating a branch fails' do
- let(:project) { create(:project_empty_repo) }
+ context 'when branch already exists' do
+ it 'returns an error' do
+ result = service.execute('master', 'master')
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('Branch already exists')
+ end
+ end
+ context 'when incorrect reference is provided' do
before do
allow(project.repository).to receive(:add_branch).and_return(false)
end
- it 'returns an error with the branch name' do
- result = service.execute('my-feature', 'master')
+ it 'returns an error with a reference name' do
+ result = service.execute('new-feature', 'unknown')
expect(result[:status]).to eq(:error)
- expect(result[:message]).to eq("Invalid reference name: my-feature")
+ expect(result[:message]).to eq('Invalid reference name: unknown')
end
end
end
diff --git a/spec/services/ci/compare_accessibility_reports_service_spec.rb b/spec/services/ci/compare_accessibility_reports_service_spec.rb
new file mode 100644
index 00000000000..aee1fd14bc5
--- /dev/null
+++ b/spec/services/ci/compare_accessibility_reports_service_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::CompareAccessibilityReportsService do
+ let(:service) { described_class.new(project) }
+ let(:project) { create(:project, :repository) }
+
+ describe '#execute' do
+ subject { service.execute(base_pipeline, head_pipeline) }
+
+ context 'when head pipeline has accessibility reports' do
+ let(:base_pipeline) { nil }
+ let(:head_pipeline) { create(:ci_pipeline, :with_accessibility_reports, project: project) }
+
+ it 'returns status and data' do
+ expect(subject[:status]).to eq(:parsed)
+ expect(subject[:data]).to match_schema('entities/accessibility_reports_comparer')
+ end
+ end
+
+ context 'when base and head pipelines have accessibility reports' do
+ let(:base_pipeline) { create(:ci_pipeline, :with_accessibility_reports, project: project) }
+ let(:head_pipeline) { create(:ci_pipeline, :with_accessibility_reports, project: project) }
+
+ it 'returns status and data' do
+ expect(subject[:status]).to eq(:parsed)
+ expect(subject[:data]).to match_schema('entities/accessibility_reports_comparer')
+ end
+ end
+ end
+
+ describe '#latest?' do
+ subject { service.latest?(base_pipeline, head_pipeline, data) }
+
+ let!(:base_pipeline) { nil }
+ let!(:head_pipeline) { create(:ci_pipeline, :with_accessibility_reports, project: project) }
+ let!(:key) { service.send(:key, base_pipeline, head_pipeline) }
+
+ context 'when cache key is latest' do
+ let(:data) { { key: key } }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when cache key is outdated' do
+ before do
+ head_pipeline.update_column(:updated_at, 10.minutes.ago)
+ end
+
+ let(:data) { { key: key } }
+
+ it { is_expected.to be_falsy }
+ end
+
+ context 'when cache key is empty' do
+ let(:data) { { key: nil } }
+
+ it { is_expected.to be_falsy }
+ end
+ end
+end
diff --git a/spec/services/ci/compare_test_reports_service_spec.rb b/spec/services/ci/compare_test_reports_service_spec.rb
index f5edd3a552d..46f4d2d42ff 100644
--- a/spec/services/ci/compare_test_reports_service_spec.rb
+++ b/spec/services/ci/compare_test_reports_service_spec.rb
@@ -38,9 +38,10 @@ describe Ci::CompareTestReportsService do
create(:ci_job_artifact, :junit_with_corrupted_data, job: build, project: project)
end
- it 'returns status and error message' do
- expect(subject[:status]).to eq(:error)
- expect(subject[:status_reason]).to include('XML parsing failed')
+ it 'returns a parsed TestReports success status and failure on the individual suite' do
+ expect(subject[:status]).to eq(:parsed)
+ expect(subject.dig(:data, 'status')).to eq('success')
+ expect(subject.dig(:data, 'suites', 0, 'status') ).to eq('error')
end
end
end
diff --git a/spec/services/ci/create_job_artifacts_service_spec.rb b/spec/services/ci/create_job_artifacts_service_spec.rb
index fe64a66f322..4d49923a184 100644
--- a/spec/services/ci/create_job_artifacts_service_spec.rb
+++ b/spec/services/ci/create_job_artifacts_service_spec.rb
@@ -30,6 +30,26 @@ describe Ci::CreateJobArtifactsService do
describe '#execute' do
subject { service.execute(job, artifacts_file, params, metadata_file: metadata_file) }
+ context 'locking' do
+ let(:old_job) { create(:ci_build, pipeline: create(:ci_pipeline, project: job.project, ref: job.ref)) }
+ let!(:latest_artifact) { create(:ci_job_artifact, job: old_job, locked: true) }
+ let!(:other_artifact) { create(:ci_job_artifact, locked: true) }
+
+ it 'locks the new artifact' do
+ subject
+
+ expect(Ci::JobArtifact.last).to have_attributes(locked: true)
+ end
+
+ it 'unlocks all other artifacts for the same ref' do
+ expect { subject }.to change { latest_artifact.reload.locked }.from(true).to(false)
+ end
+
+ it 'does not unlock artifacts for other refs' do
+ expect { subject }.not_to change { other_artifact.reload.locked }.from(true)
+ end
+ end
+
context 'when artifacts file is uploaded' do
it 'saves artifact for the given type' do
expect { subject }.to change { Ci::JobArtifact.count }.by(1)
@@ -157,6 +177,53 @@ describe Ci::CreateJobArtifactsService do
end
end
+ context 'when artifact type is cluster_applications' do
+ let(:artifacts_file) do
+ file_to_upload('spec/fixtures/helm/helm_list_v2_prometheus_missing.json.gz', sha256: artifacts_sha256)
+ end
+
+ let(:params) do
+ {
+ 'artifact_type' => 'cluster_applications',
+ 'artifact_format' => 'gzip'
+ }
+ end
+
+ it 'calls cluster applications parse service' do
+ expect_next_instance_of(Clusters::ParseClusterApplicationsArtifactService) do |service|
+ expect(service).to receive(:execute).once.and_call_original
+ end
+
+ subject
+ end
+
+ context 'when there is a deployment cluster' do
+ let(:user) { project.owner }
+
+ before do
+ job.update!(user: user)
+ end
+
+ it 'calls cluster applications parse service with job and job user', :aggregate_failures do
+ expect(Clusters::ParseClusterApplicationsArtifactService).to receive(:new).with(job, user).and_call_original
+
+ subject
+ end
+ end
+
+ context 'when ci_synchronous_artifact_parsing feature flag is disabled' do
+ before do
+ stub_feature_flags(ci_synchronous_artifact_parsing: false)
+ end
+
+ it 'does not call parse service' do
+ expect(Clusters::ParseClusterApplicationsArtifactService).not_to receive(:new)
+
+ expect(subject[:status]).to eq(:success)
+ end
+ end
+ end
+
shared_examples 'rescues object storage error' do |klass, message, expected_message|
it "handles #{klass}" do
allow_next_instance_of(JobArtifactUploader) do |uploader|
diff --git a/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb b/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb
index 112b19fcbc5..5980260a08a 100644
--- a/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb
+++ b/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb
@@ -34,7 +34,7 @@ describe Ci::CreatePipelineService do
it 'creates a pipeline using the content passed in as param' do
expect(subject).to be_persisted
- expect(subject.builds.map(&:name)).to eq %w[rspec custom]
+ expect(subject.builds.pluck(:name)).to match_array %w[rspec custom]
expect(subject.config_source).to eq 'bridge_source'
end
@@ -59,7 +59,7 @@ describe Ci::CreatePipelineService do
it 'created a pipeline using the content passed in as param and download the artifact' do
expect(subject).to be_persisted
- expect(subject.builds.pluck(:name)).to eq %w[rspec time custom]
+ expect(subject.builds.pluck(:name)).to match_array %w[rspec time custom]
expect(subject.config_source).to eq 'bridge_source'
end
end
diff --git a/spec/services/ci/daily_build_group_report_result_service_spec.rb b/spec/services/ci/daily_build_group_report_result_service_spec.rb
new file mode 100644
index 00000000000..f0b72b8fd86
--- /dev/null
+++ b/spec/services/ci/daily_build_group_report_result_service_spec.rb
@@ -0,0 +1,158 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::DailyBuildGroupReportResultService, '#execute' do
+ let!(:pipeline) { create(:ci_pipeline, created_at: '2020-02-06 00:01:10') }
+ let!(:rspec_job) { create(:ci_build, pipeline: pipeline, name: '3/3 rspec', coverage: 80) }
+ let!(:karma_job) { create(:ci_build, pipeline: pipeline, name: '2/2 karma', coverage: 90) }
+ let!(:extra_job) { create(:ci_build, pipeline: pipeline, name: 'extra', coverage: nil) }
+
+ it 'creates daily code coverage record for each job in the pipeline that has coverage value' do
+ described_class.new.execute(pipeline)
+
+ Ci::DailyBuildGroupReportResult.find_by(group_name: 'rspec').tap do |coverage|
+ expect(coverage).to have_attributes(
+ project_id: pipeline.project.id,
+ last_pipeline_id: pipeline.id,
+ ref_path: pipeline.source_ref_path,
+ group_name: rspec_job.group_name,
+ data: { 'coverage' => rspec_job.coverage },
+ date: pipeline.created_at.to_date
+ )
+ end
+
+ Ci::DailyBuildGroupReportResult.find_by(group_name: 'karma').tap do |coverage|
+ expect(coverage).to have_attributes(
+ project_id: pipeline.project.id,
+ last_pipeline_id: pipeline.id,
+ ref_path: pipeline.source_ref_path,
+ group_name: karma_job.group_name,
+ data: { 'coverage' => karma_job.coverage },
+ date: pipeline.created_at.to_date
+ )
+ end
+
+ expect(Ci::DailyBuildGroupReportResult.find_by(group_name: 'extra')).to be_nil
+ end
+
+ context 'when there are multiple builds with the same group name that report coverage' do
+ let!(:test_job_1) { create(:ci_build, pipeline: pipeline, name: '1/2 test', coverage: 70) }
+ let!(:test_job_2) { create(:ci_build, pipeline: pipeline, name: '2/2 test', coverage: 80) }
+
+ it 'creates daily code coverage record with the average as the value' do
+ described_class.new.execute(pipeline)
+
+ Ci::DailyBuildGroupReportResult.find_by(group_name: 'test').tap do |coverage|
+ expect(coverage).to have_attributes(
+ project_id: pipeline.project.id,
+ last_pipeline_id: pipeline.id,
+ ref_path: pipeline.source_ref_path,
+ group_name: test_job_2.group_name,
+ data: { 'coverage' => 75.0 },
+ date: pipeline.created_at.to_date
+ )
+ end
+ end
+ end
+
+ context 'when there is an existing daily code coverage for the matching date, project, ref_path, and group name' do
+ let!(:new_pipeline) do
+ create(
+ :ci_pipeline,
+ project: pipeline.project,
+ ref: pipeline.ref,
+ created_at: '2020-02-06 00:02:20'
+ )
+ end
+ let!(:new_rspec_job) { create(:ci_build, pipeline: new_pipeline, name: '4/4 rspec', coverage: 84) }
+ let!(:new_karma_job) { create(:ci_build, pipeline: new_pipeline, name: '3/3 karma', coverage: 92) }
+
+ before do
+ # Create the existing daily code coverage records
+ described_class.new.execute(pipeline)
+ end
+
+ it "updates the existing record's coverage value and last_pipeline_id" do
+ rspec_coverage = Ci::DailyBuildGroupReportResult.find_by(group_name: 'rspec')
+ karma_coverage = Ci::DailyBuildGroupReportResult.find_by(group_name: 'karma')
+
+ # Bump up the coverage values
+ described_class.new.execute(new_pipeline)
+
+ rspec_coverage.reload
+ karma_coverage.reload
+
+ expect(rspec_coverage).to have_attributes(
+ last_pipeline_id: new_pipeline.id,
+ data: { 'coverage' => new_rspec_job.coverage }
+ )
+
+ expect(karma_coverage).to have_attributes(
+ last_pipeline_id: new_pipeline.id,
+ data: { 'coverage' => new_karma_job.coverage }
+ )
+ end
+ end
+
+ context 'when the ID of the pipeline is older than the last_pipeline_id' do
+ let!(:new_pipeline) do
+ create(
+ :ci_pipeline,
+ project: pipeline.project,
+ ref: pipeline.ref,
+ created_at: '2020-02-06 00:02:20'
+ )
+ end
+ let!(:new_rspec_job) { create(:ci_build, pipeline: new_pipeline, name: '4/4 rspec', coverage: 84) }
+ let!(:new_karma_job) { create(:ci_build, pipeline: new_pipeline, name: '3/3 karma', coverage: 92) }
+
+ before do
+ # Create the existing daily code coverage records
+ # but in this case, for the newer pipeline first.
+ described_class.new.execute(new_pipeline)
+ end
+
+ it 'updates the existing daily code coverage records' do
+ rspec_coverage = Ci::DailyBuildGroupReportResult.find_by(group_name: 'rspec')
+ karma_coverage = Ci::DailyBuildGroupReportResult.find_by(group_name: 'karma')
+
+ # Run another one but for the older pipeline.
+ # This simulates the scenario wherein the success worker
+ # of an older pipeline, for some network hiccup, was delayed
+ # and only got executed right after the newer pipeline's success worker.
+ # Ideally, we don't want to bump the coverage value with an older one
+ # but given this can be a rare edge case and can be remedied by re-running
+ # the pipeline we'll just let it be for now. In return, we are able to use
+ # Rails 6 shiny new method, upsert_all, and simplify the code a lot.
+ described_class.new.execute(pipeline)
+
+ rspec_coverage.reload
+ karma_coverage.reload
+
+ expect(rspec_coverage).to have_attributes(
+ last_pipeline_id: pipeline.id,
+ data: { 'coverage' => rspec_job.coverage }
+ )
+
+ expect(karma_coverage).to have_attributes(
+ last_pipeline_id: pipeline.id,
+ data: { 'coverage' => karma_job.coverage }
+ )
+ end
+ end
+
+ context 'when pipeline has no builds with coverage' do
+ let!(:new_pipeline) do
+ create(
+ :ci_pipeline,
+ created_at: '2020-02-06 00:02:20'
+ )
+ end
+ let!(:some_job) { create(:ci_build, pipeline: new_pipeline, name: 'foo') }
+
+ it 'does nothing' do
+ expect { described_class.new.execute(new_pipeline) }.not_to raise_error
+ end
+ end
+end
diff --git a/spec/services/ci/daily_report_result_service_spec.rb b/spec/services/ci/daily_report_result_service_spec.rb
deleted file mode 100644
index 240709bab0b..00000000000
--- a/spec/services/ci/daily_report_result_service_spec.rb
+++ /dev/null
@@ -1,161 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe Ci::DailyReportResultService, '#execute' do
- let!(:pipeline) { create(:ci_pipeline, created_at: '2020-02-06 00:01:10') }
- let!(:rspec_job) { create(:ci_build, pipeline: pipeline, name: '3/3 rspec', coverage: 80) }
- let!(:karma_job) { create(:ci_build, pipeline: pipeline, name: '2/2 karma', coverage: 90) }
- let!(:extra_job) { create(:ci_build, pipeline: pipeline, name: 'extra', coverage: nil) }
-
- it 'creates daily code coverage record for each job in the pipeline that has coverage value' do
- described_class.new.execute(pipeline)
-
- Ci::DailyReportResult.find_by(title: 'rspec').tap do |coverage|
- expect(coverage).to have_attributes(
- project_id: pipeline.project.id,
- last_pipeline_id: pipeline.id,
- ref_path: pipeline.source_ref_path,
- param_type: 'coverage',
- title: rspec_job.group_name,
- value: rspec_job.coverage,
- date: pipeline.created_at.to_date
- )
- end
-
- Ci::DailyReportResult.find_by(title: 'karma').tap do |coverage|
- expect(coverage).to have_attributes(
- project_id: pipeline.project.id,
- last_pipeline_id: pipeline.id,
- ref_path: pipeline.source_ref_path,
- param_type: 'coverage',
- title: karma_job.group_name,
- value: karma_job.coverage,
- date: pipeline.created_at.to_date
- )
- end
-
- expect(Ci::DailyReportResult.find_by(title: 'extra')).to be_nil
- end
-
- context 'when there are multiple builds with the same group name that report coverage' do
- let!(:test_job_1) { create(:ci_build, pipeline: pipeline, name: '1/2 test', coverage: 70) }
- let!(:test_job_2) { create(:ci_build, pipeline: pipeline, name: '2/2 test', coverage: 80) }
-
- it 'creates daily code coverage record with the average as the value' do
- described_class.new.execute(pipeline)
-
- Ci::DailyReportResult.find_by(title: 'test').tap do |coverage|
- expect(coverage).to have_attributes(
- project_id: pipeline.project.id,
- last_pipeline_id: pipeline.id,
- ref_path: pipeline.source_ref_path,
- param_type: 'coverage',
- title: test_job_2.group_name,
- value: 75,
- date: pipeline.created_at.to_date
- )
- end
- end
- end
-
- context 'when there is an existing daily code coverage for the matching date, project, ref_path, and group name' do
- let!(:new_pipeline) do
- create(
- :ci_pipeline,
- project: pipeline.project,
- ref: pipeline.ref,
- created_at: '2020-02-06 00:02:20'
- )
- end
- let!(:new_rspec_job) { create(:ci_build, pipeline: new_pipeline, name: '4/4 rspec', coverage: 84) }
- let!(:new_karma_job) { create(:ci_build, pipeline: new_pipeline, name: '3/3 karma', coverage: 92) }
-
- before do
- # Create the existing daily code coverage records
- described_class.new.execute(pipeline)
- end
-
- it "updates the existing record's coverage value and last_pipeline_id" do
- rspec_coverage = Ci::DailyReportResult.find_by(title: 'rspec')
- karma_coverage = Ci::DailyReportResult.find_by(title: 'karma')
-
- # Bump up the coverage values
- described_class.new.execute(new_pipeline)
-
- rspec_coverage.reload
- karma_coverage.reload
-
- expect(rspec_coverage).to have_attributes(
- last_pipeline_id: new_pipeline.id,
- value: new_rspec_job.coverage
- )
-
- expect(karma_coverage).to have_attributes(
- last_pipeline_id: new_pipeline.id,
- value: new_karma_job.coverage
- )
- end
- end
-
- context 'when the ID of the pipeline is older than the last_pipeline_id' do
- let!(:new_pipeline) do
- create(
- :ci_pipeline,
- project: pipeline.project,
- ref: pipeline.ref,
- created_at: '2020-02-06 00:02:20'
- )
- end
- let!(:new_rspec_job) { create(:ci_build, pipeline: new_pipeline, name: '4/4 rspec', coverage: 84) }
- let!(:new_karma_job) { create(:ci_build, pipeline: new_pipeline, name: '3/3 karma', coverage: 92) }
-
- before do
- # Create the existing daily code coverage records
- # but in this case, for the newer pipeline first.
- described_class.new.execute(new_pipeline)
- end
-
- it 'updates the existing daily code coverage records' do
- rspec_coverage = Ci::DailyReportResult.find_by(title: 'rspec')
- karma_coverage = Ci::DailyReportResult.find_by(title: 'karma')
-
- # Run another one but for the older pipeline.
- # This simulates the scenario wherein the success worker
- # of an older pipeline, for some network hiccup, was delayed
- # and only got executed right after the newer pipeline's success worker.
- # Ideally, we don't want to bump the coverage value with an older one
- # but given this can be a rare edge case and can be remedied by re-running
- # the pipeline we'll just let it be for now. In return, we are able to use
- # Rails 6 shiny new method, upsert_all, and simplify the code a lot.
- described_class.new.execute(pipeline)
-
- rspec_coverage.reload
- karma_coverage.reload
-
- expect(rspec_coverage).to have_attributes(
- last_pipeline_id: pipeline.id,
- value: rspec_job.coverage
- )
-
- expect(karma_coverage).to have_attributes(
- last_pipeline_id: pipeline.id,
- value: karma_job.coverage
- )
- end
- end
-
- context 'when pipeline has no builds with coverage' do
- let!(:new_pipeline) do
- create(
- :ci_pipeline,
- created_at: '2020-02-06 00:02:20'
- )
- end
- let!(:some_job) { create(:ci_build, pipeline: new_pipeline, name: 'foo') }
-
- it 'does nothing' do
- expect { described_class.new.execute(new_pipeline) }.not_to raise_error
- end
- 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 fc5450ab33d..4b9f12d8fdf 100644
--- a/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
+++ b/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
@@ -11,8 +11,26 @@ describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared_state
let(:service) { described_class.new }
let!(:artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) }
- it 'destroys expired job artifacts' do
- expect { subject }.to change { Ci::JobArtifact.count }.by(-1)
+ context 'when artifact is expired' do
+ context 'when artifact is not locked' do
+ before do
+ artifact.update!(locked: false)
+ end
+
+ it 'destroys job artifact' do
+ expect { subject }.to change { Ci::JobArtifact.count }.by(-1)
+ end
+ end
+
+ context 'when artifact is locked' do
+ before do
+ artifact.update!(locked: true)
+ end
+
+ it 'does not destroy job artifact' do
+ expect { subject }.not_to change { Ci::JobArtifact.count }
+ end
+ end
end
context 'when artifact is not expired' do
@@ -72,7 +90,7 @@ describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared_state
stub_const('Ci::DestroyExpiredJobArtifactsService::BATCH_SIZE', 1)
end
- let!(:artifact) { create_list(:ci_job_artifact, 2, expire_at: 1.day.ago) }
+ let!(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) }
it 'raises an error and does not continue destroying' do
is_expected.to be_falsy
@@ -96,7 +114,7 @@ describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared_state
stub_const('Ci::DestroyExpiredJobArtifactsService::BATCH_SIZE', 1)
end
- let!(:artifact) { create_list(:ci_job_artifact, 2, expire_at: 1.day.ago) }
+ let!(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) }
it 'destroys all expired artifacts' do
expect { subject }.to change { Ci::JobArtifact.count }.by(-2)
diff --git a/spec/services/ci/generate_terraform_reports_service_spec.rb b/spec/services/ci/generate_terraform_reports_service_spec.rb
new file mode 100644
index 00000000000..4d2c60bed2c
--- /dev/null
+++ b/spec/services/ci/generate_terraform_reports_service_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::GenerateTerraformReportsService do
+ let_it_be(:project) { create(:project, :repository) }
+
+ describe '#execute' do
+ let_it_be(:merge_request) { create(:merge_request, :with_terraform_reports, source_project: project) }
+
+ subject { described_class.new(project, nil, id: merge_request.id) }
+
+ context 'when head pipeline has terraform reports' do
+ it 'returns status and data' do
+ result = subject.execute(nil, merge_request.head_pipeline)
+
+ expect(result).to match(
+ status: :parsed,
+ data: match(
+ a_hash_including('tfplan.json' => a_hash_including('create' => 0, 'update' => 1, 'delete' => 0))
+ ),
+ key: an_instance_of(Array)
+ )
+ end
+ end
+
+ context 'when head pipeline has corrupted terraform reports' do
+ it 'returns status and error message' do
+ build = create(:ci_build, pipeline: merge_request.head_pipeline, project: project)
+ create(:ci_job_artifact, :terraform_with_corrupted_data, job: build, project: project)
+
+ result = subject.execute(nil, merge_request.head_pipeline)
+
+ expect(result).to match(
+ status: :error,
+ status_reason: 'An error occurred while fetching terraform reports.',
+ key: an_instance_of(Array)
+ )
+ end
+ end
+ end
+
+ describe '#latest?' do
+ let_it_be(:head_pipeline) { create(:ci_pipeline, :with_test_reports, project: project) }
+
+ subject { described_class.new(project) }
+
+ it 'returns true when cache key is latest' do
+ cache_key = subject.send(:key, nil, head_pipeline)
+
+ result = subject.latest?(nil, head_pipeline, key: cache_key)
+
+ expect(result).to eq(true)
+ end
+
+ it 'returns false when cache key is outdated' do
+ cache_key = subject.send(:key, nil, head_pipeline)
+ head_pipeline.update_column(:updated_at, 10.minutes.ago)
+
+ result = subject.latest?(nil, head_pipeline, key: cache_key)
+
+ expect(result).to eq(false)
+ end
+
+ it 'returns false when cache key is nil' do
+ result = subject.latest?(nil, head_pipeline, key: nil)
+
+ expect(result).to eq(false)
+ end
+ end
+end
diff --git a/spec/services/ci/pipeline_processing/atomic_processing_service/status_collection_spec.rb b/spec/services/ci/pipeline_processing/atomic_processing_service/status_collection_spec.rb
index b487730d07f..de3c7713ac8 100644
--- a/spec/services/ci/pipeline_processing/atomic_processing_service/status_collection_spec.rb
+++ b/spec/services/ci/pipeline_processing/atomic_processing_service/status_collection_spec.rb
@@ -18,7 +18,7 @@ describe Ci::PipelineProcessing::AtomicProcessingService::StatusCollection do
it 'does update existing status of processable' do
collection.set_processable_status(test_a.id, 'success', 100)
- expect(collection.status_for_names(['test-a'])).to eq('success')
+ expect(collection.status_for_names(['test-a'], dag: false)).to eq('success')
end
it 'ignores a missing processable' do
@@ -33,15 +33,18 @@ describe Ci::PipelineProcessing::AtomicProcessingService::StatusCollection do
end
describe '#status_for_names' do
- where(:names, :status) do
- %w[build-a] | 'success'
- %w[build-a build-b] | 'failed'
- %w[build-a test-a] | 'running'
+ where(:names, :status, :dag) do
+ %w[build-a] | 'success' | false
+ %w[build-a build-b] | 'failed' | false
+ %w[build-a test-a] | 'running' | false
+ %w[build-a] | 'success' | true
+ %w[build-a build-b] | 'failed' | true
+ %w[build-a test-a] | 'pending' | true
end
with_them do
it 'returns composite status of given names' do
- expect(collection.status_for_names(names)).to eq(status)
+ expect(collection.status_for_names(names, dag: dag)).to eq(status)
end
end
end
diff --git a/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb
index cbeb45b92ff..3b66ecff196 100644
--- a/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb
+++ b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb
@@ -2,13 +2,19 @@
require 'spec_helper'
require_relative 'shared_processing_service.rb'
+require_relative 'shared_processing_service_tests_with_yaml.rb'
describe Ci::PipelineProcessing::AtomicProcessingService do
before do
stub_feature_flags(ci_atomic_processing: true)
+
+ # This feature flag is implicit
+ # Atomic Processing does not process statuses differently
+ stub_feature_flags(ci_composite_status: true)
end
it_behaves_like 'Pipeline Processing Service'
+ it_behaves_like 'Pipeline Processing Service Tests With Yaml'
private
diff --git a/spec/services/ci/pipeline_processing/legacy_processing_service_spec.rb b/spec/services/ci/pipeline_processing/legacy_processing_service_spec.rb
index 09b462b7600..fd491bf461b 100644
--- a/spec/services/ci/pipeline_processing/legacy_processing_service_spec.rb
+++ b/spec/services/ci/pipeline_processing/legacy_processing_service_spec.rb
@@ -2,13 +2,30 @@
require 'spec_helper'
require_relative 'shared_processing_service.rb'
+require_relative 'shared_processing_service_tests_with_yaml.rb'
describe Ci::PipelineProcessing::LegacyProcessingService do
before do
stub_feature_flags(ci_atomic_processing: false)
end
- it_behaves_like 'Pipeline Processing Service'
+ context 'when ci_composite_status is enabled' do
+ before do
+ stub_feature_flags(ci_composite_status: true)
+ end
+
+ it_behaves_like 'Pipeline Processing Service'
+ it_behaves_like 'Pipeline Processing Service Tests With Yaml'
+ end
+
+ context 'when ci_composite_status is disabled' do
+ before do
+ stub_feature_flags(ci_composite_status: false)
+ end
+
+ it_behaves_like 'Pipeline Processing Service'
+ it_behaves_like 'Pipeline Processing Service Tests With Yaml'
+ end
private
diff --git a/spec/services/ci/pipeline_processing/shared_processing_service.rb b/spec/services/ci/pipeline_processing/shared_processing_service.rb
index ffe5eacfc48..29fa43001ae 100644
--- a/spec/services/ci/pipeline_processing/shared_processing_service.rb
+++ b/spec/services/ci/pipeline_processing/shared_processing_service.rb
@@ -816,10 +816,10 @@ shared_examples 'Pipeline Processing Service' do
context 'when a needed job is skipped', :sidekiq_inline do
let!(:linux_build) { create_build('linux:build', stage: 'build', stage_idx: 0) }
let!(:linux_rspec) { create_build('linux:rspec', stage: 'test', stage_idx: 1) }
- let!(:deploy) do
- create_build('deploy', stage: 'deploy', stage_idx: 2, scheduling_type: :dag, needs: [
- create(:ci_build_need, name: 'linux:rspec')
- ])
+ let!(:deploy) { create_build('deploy', stage: 'deploy', stage_idx: 2, scheduling_type: :dag) }
+
+ before do
+ create(:ci_build_need, build: deploy, name: 'linux:build')
end
it 'skips the jobs depending on it' do
@@ -836,6 +836,23 @@ shared_examples 'Pipeline Processing Service' do
end
end
+ context 'when a needed job is manual', :sidekiq_inline do
+ let!(:linux_build) { create_build('linux:build', stage: 'build', stage_idx: 0, when: 'manual', allow_failure: true) }
+ let!(:deploy) { create_build('deploy', stage: 'deploy', stage_idx: 1, scheduling_type: :dag) }
+
+ before do
+ create(:ci_build_need, build: deploy, name: 'linux:build')
+ end
+
+ it 'makes deploy DAG to be waiting for optional manual to finish' do
+ expect(process_pipeline).to be_truthy
+
+ expect(stages).to eq(%w(skipped created))
+ expect(all_builds.manual).to contain_exactly(linux_build)
+ expect(all_builds.created).to contain_exactly(deploy)
+ end
+ end
+
private
def all_builds
diff --git a/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
new file mode 100644
index 00000000000..93f83f0ea3b
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+shared_context 'Pipeline Processing Service Tests With Yaml' do
+ where(:test_file_path) do
+ Dir.glob(Rails.root.join('spec/services/ci/pipeline_processing/test_cases/*.yml'))
+ end
+
+ with_them do
+ let(:test_file) { YAML.load_file(test_file_path) }
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+ let(:pipeline) { Ci::CreatePipelineService.new(project, user, ref: 'master').execute(:pipeline) }
+
+ before do
+ stub_ci_pipeline_yaml_file(YAML.dump(test_file['config']))
+ stub_not_protect_default_branch
+ project.add_developer(user)
+ end
+
+ it 'follows transitions' do
+ expect(pipeline).to be_persisted
+ Sidekiq::Worker.drain_all # ensure that all async jobs are executed
+ check_expectation(test_file.dig('init', 'expect'), "init")
+
+ test_file['transitions'].each_with_index do |transition, idx|
+ event_on_jobs(transition['event'], transition['jobs'])
+ Sidekiq::Worker.drain_all # ensure that all async jobs are executed
+ check_expectation(transition['expect'], "transition:#{idx}")
+ end
+ end
+
+ private
+
+ def check_expectation(expectation, message)
+ expect(current_state.deep_stringify_keys).to eq(expectation), message
+ end
+
+ def current_state
+ # reload pipeline and all relations
+ pipeline.reload
+
+ {
+ pipeline: pipeline.status,
+ stages: pipeline.ordered_stages.pluck(:name, :status).to_h,
+ jobs: pipeline.statuses.latest.pluck(:name, :status).to_h
+ }
+ end
+
+ def event_on_jobs(event, job_names)
+ statuses = pipeline.statuses.latest.by_name(job_names).to_a
+ expect(statuses.count).to eq(job_names.count) # ensure that we have the same counts
+
+ statuses.each { |status| status.public_send("#{event}!") }
+ end
+ end
+end
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_allow_failure_test_on_failure.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_allow_failure_test_on_failure.yml
new file mode 100644
index 00000000000..cfc456387ff
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_allow_failure_test_on_failure.yml
@@ -0,0 +1,47 @@
+config:
+ build:
+ stage: build
+ allow_failure: true
+ script: exit 1
+
+ test:
+ stage: test
+ when: on_failure
+ script: exit 0
+ needs: [build]
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ needs: [test]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: drop
+ jobs: [build]
+ expect:
+ pipeline: success
+ stages:
+ build: success
+ test: skipped
+ deploy: skipped
+ jobs:
+ build: failed
+ test: skipped
+ deploy: skipped
+
+# TODO: What is the real expected behavior here?
+# Is `needs` keyword a requirement indicator or just a helper to build dependency tree?
+# How should it behave `when: on_failure` with `needs`?
+# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_fails.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails.yml
new file mode 100644
index 00000000000..e71ef194c5f
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails.yml
@@ -0,0 +1,39 @@
+config:
+ build:
+ stage: build
+ script: exit 1
+
+ test:
+ stage: test
+ script: exit 0
+ needs: [build]
+
+ deploy:
+ stage: deploy
+ script: exit 0
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: drop
+ jobs: [build]
+ expect:
+ pipeline: failed
+ stages:
+ build: failed
+ test: skipped
+ deploy: skipped
+ jobs:
+ build: failed
+ test: skipped
+ deploy: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_deploy_needs_test.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_deploy_needs_test.yml
new file mode 100644
index 00000000000..40a80f6f53b
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_deploy_needs_test.yml
@@ -0,0 +1,39 @@
+config:
+ build:
+ stage: build
+ script: exit 1
+
+ test:
+ stage: test
+ script: exit 0
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ needs: [test]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: drop
+ jobs: [build]
+ expect:
+ pipeline: failed
+ stages:
+ build: failed
+ test: skipped
+ deploy: skipped
+ jobs:
+ build: failed
+ test: skipped
+ deploy: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_deploy_needs_test_when_always.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_deploy_needs_test_when_always.yml
new file mode 100644
index 00000000000..b0904a027f8
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_deploy_needs_test_when_always.yml
@@ -0,0 +1,43 @@
+config:
+ build:
+ stage: build
+ script: exit 1
+
+ test:
+ stage: test
+ script: exit 0
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ when: always
+ needs: [test]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: drop
+ jobs: [build]
+ expect:
+ pipeline: running
+ stages:
+ build: failed
+ test: skipped
+ deploy: pending
+ jobs:
+ build: failed
+ test: skipped
+ deploy: pending
+
+# TODO: `test` is actually skipped, but we run `deploy`. Should we?
+# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_other_build_succeeds.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_other_build_succeeds.yml
new file mode 100644
index 00000000000..a133023b12d
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_other_build_succeeds.yml
@@ -0,0 +1,62 @@
+config:
+ build_1:
+ stage: build
+ script: exit 0
+
+ build_2:
+ stage: build
+ script: exit 1
+
+ test:
+ stage: test
+ script: exit 0
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ needs: [build_1, test]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build_1: pending
+ build_2: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: success
+ jobs: [build_1]
+ expect:
+ pipeline: running
+ stages:
+ build: running
+ test: created
+ deploy: created
+ jobs:
+ build_1: success
+ build_2: pending
+ test: created
+ deploy: created
+
+ - event: drop
+ jobs: [build_2]
+ expect:
+ pipeline: running
+ stages:
+ build: failed
+ test: skipped
+ deploy: pending
+ 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
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_other_build_succeeds_deploy_always.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_other_build_succeeds_deploy_always.yml
new file mode 100644
index 00000000000..4c676761e5c
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_other_build_succeeds_deploy_always.yml
@@ -0,0 +1,63 @@
+config:
+ build_1:
+ stage: build
+ script: exit 0
+
+ build_2:
+ stage: build
+ script: exit 1
+
+ test:
+ stage: test
+ script: exit 0
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ when: always
+ needs: [build_1, test]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build_1: pending
+ build_2: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: success
+ jobs: [build_1]
+ expect:
+ pipeline: running
+ stages:
+ build: running
+ test: created
+ deploy: created
+ jobs:
+ build_1: success
+ build_2: pending
+ test: created
+ deploy: created
+
+ - event: drop
+ jobs: [build_2]
+ expect:
+ pipeline: running
+ stages:
+ build: failed
+ test: skipped
+ deploy: pending
+ jobs:
+ build_1: success
+ build_2: failed
+ test: skipped
+ deploy: pending
+
+# TODO: what's the actual expected behavior here?
+# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_test_allow_failure.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_test_allow_failure.yml
new file mode 100644
index 00000000000..ea7046262c3
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_test_allow_failure.yml
@@ -0,0 +1,40 @@
+config:
+ build:
+ stage: build
+ script: exit 1
+
+ test:
+ stage: test
+ allow_failure: true
+ script: exit 1
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ needs: [test]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: drop
+ jobs: [build]
+ expect:
+ pipeline: failed
+ stages:
+ build: failed
+ test: skipped
+ deploy: skipped
+ jobs:
+ build: failed
+ test: skipped
+ deploy: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_test_always.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_test_always.yml
new file mode 100644
index 00000000000..8860f565cc7
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_test_always.yml
@@ -0,0 +1,35 @@
+config:
+ build:
+ stage: build
+ script: exit 1
+
+ test:
+ stage: test
+ when: always
+ script: exit 0
+ needs: [build]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ jobs:
+ build: pending
+ test: created
+
+transitions:
+ - event: drop
+ jobs: [build]
+ expect:
+ pipeline: running
+ stages:
+ build: failed
+ test: pending
+ jobs:
+ build: failed
+ test: pending
+
+# TODO: Should we run `test`?
+# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_test_on_failure.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_test_on_failure.yml
new file mode 100644
index 00000000000..3fa5a8034a2
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_fails_test_on_failure.yml
@@ -0,0 +1,35 @@
+config:
+ build:
+ stage: build
+ script: exit 1
+
+ test:
+ stage: test
+ when: on_failure
+ script: exit 0
+ needs: [build]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ jobs:
+ build: pending
+ test: created
+
+transitions:
+ - event: drop
+ jobs: [build]
+ expect:
+ pipeline: running
+ stages:
+ build: failed
+ test: pending
+ jobs:
+ build: failed
+ test: pending
+
+# TODO: Should we run `test`?
+# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_succeeds_test_on_failure.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_succeeds_test_on_failure.yml
new file mode 100644
index 00000000000..700d4440802
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_succeeds_test_on_failure.yml
@@ -0,0 +1,35 @@
+config:
+ build:
+ stage: build
+ script: exit 0
+
+ test:
+ stage: test
+ when: on_failure
+ script: exit 0
+ needs: [build]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ jobs:
+ build: pending
+ test: created
+
+transitions:
+ - event: success
+ jobs: [build]
+ expect:
+ pipeline: success
+ stages:
+ build: success
+ test: skipped
+ jobs:
+ build: success
+ test: skipped
+
+# TODO: Should we run `test`?
+# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_builds_succeed_test_on_failure.yml b/spec/services/ci/pipeline_processing/test_cases/dag_builds_succeed_test_on_failure.yml
new file mode 100644
index 00000000000..f324525bd56
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_builds_succeed_test_on_failure.yml
@@ -0,0 +1,63 @@
+config:
+ build_1:
+ stage: build
+ script: exit 0
+
+ build_2:
+ stage: build
+ script: exit 0
+
+ test:
+ stage: test
+ script: exit 0
+ when: on_failure
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ needs: [build_1, test]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build_1: pending
+ build_2: pending
+ test: created
+ deploy: created
+
+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
+ 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
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_builds_succeed_test_on_failure_deploy_always.yml b/spec/services/ci/pipeline_processing/test_cases/dag_builds_succeed_test_on_failure_deploy_always.yml
new file mode 100644
index 00000000000..9986dbaa215
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_builds_succeed_test_on_failure_deploy_always.yml
@@ -0,0 +1,64 @@
+config:
+ build_1:
+ stage: build
+ script: exit 0
+
+ build_2:
+ stage: build
+ script: exit 0
+
+ test:
+ stage: test
+ script: exit 0
+ when: on_failure
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ when: always
+ needs: [build_1, test]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build_1: pending
+ build_2: pending
+ test: created
+ deploy: created
+
+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
+ 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
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_allow_failure_true.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_allow_failure_true.yml
new file mode 100644
index 00000000000..8d4d9d403f1
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_allow_failure_true.yml
@@ -0,0 +1,43 @@
+config:
+ test:
+ stage: test
+ allow_failure: true
+ script: exit 1
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ needs: [test]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ test: pending
+ deploy: created
+ jobs:
+ test: pending
+ deploy: created
+
+transitions:
+ - event: drop
+ jobs: [test]
+ expect:
+ pipeline: pending
+ stages:
+ test: success
+ deploy: pending
+ jobs:
+ test: failed
+ deploy: pending
+
+ - event: success
+ jobs: [deploy]
+ expect:
+ pipeline: success
+ stages:
+ test: success
+ deploy: success
+ jobs:
+ test: failed
+ deploy: success
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_false.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_false.yml
new file mode 100644
index 00000000000..1d61cd24f8c
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_false.yml
@@ -0,0 +1,66 @@
+config:
+ test:
+ stage: test
+ when: manual
+ allow_failure: false
+ script: exit 0
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ needs: [test]
+
+init:
+ expect:
+ pipeline: manual
+ stages:
+ test: manual
+ deploy: created
+ jobs:
+ test: manual
+ deploy: created
+
+transitions:
+ - event: enqueue
+ jobs: [test]
+ expect:
+ pipeline: pending
+ stages:
+ test: pending
+ deploy: created
+ jobs:
+ test: pending
+ deploy: created
+
+ - event: run
+ jobs: [test]
+ expect:
+ pipeline: running
+ stages:
+ test: running
+ deploy: created
+ jobs:
+ test: running
+ deploy: created
+
+ - event: success
+ jobs: [test]
+ expect:
+ pipeline: running
+ stages:
+ test: success
+ deploy: pending
+ jobs:
+ test: success
+ deploy: pending
+
+ - event: success
+ jobs: [deploy]
+ expect:
+ pipeline: success
+ stages:
+ test: success
+ deploy: success
+ jobs:
+ test: success
+ deploy: success
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true.yml
new file mode 100644
index 00000000000..d8ca563b141
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true.yml
@@ -0,0 +1,58 @@
+config:
+ test:
+ stage: test
+ when: manual
+ allow_failure: true
+ script: exit 1
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ needs: [test]
+
+init:
+ expect:
+ pipeline: created
+ stages:
+ test: skipped
+ deploy: created
+ jobs:
+ test: manual
+ deploy: created
+
+transitions:
+ - event: enqueue
+ jobs: [test]
+ expect:
+ pipeline: pending
+ stages:
+ test: pending
+ deploy: created
+ jobs:
+ test: pending
+ deploy: created
+
+ - event: run
+ jobs: [test]
+ expect:
+ pipeline: running
+ stages:
+ test: running
+ deploy: created
+ jobs:
+ test: running
+ deploy: created
+
+ - event: drop
+ jobs: [test]
+ expect:
+ pipeline: running
+ stages:
+ test: success
+ deploy: pending
+ jobs:
+ test: failed
+ deploy: pending
+
+# TOOD: should we run deploy?
+# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_always.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_always.yml
new file mode 100644
index 00000000000..ba0a20f49a7
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_always.yml
@@ -0,0 +1,27 @@
+config:
+ test:
+ stage: test
+ when: manual
+ allow_failure: true
+ script: exit 1
+
+ deploy:
+ stage: deploy
+ when: always
+ script: exit 0
+ needs: [test]
+
+init:
+ expect:
+ pipeline: created
+ stages:
+ test: skipped
+ deploy: created
+ jobs:
+ test: manual
+ deploy: created
+
+transitions: []
+
+# TODO: should we run `deploy`?
+# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_on_failure.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_on_failure.yml
new file mode 100644
index 00000000000..d375c6a49e0
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_on_failure.yml
@@ -0,0 +1,48 @@
+config:
+ test:
+ stage: test
+ when: manual
+ allow_failure: true
+ script: exit 1
+
+ deploy:
+ stage: deploy
+ when: on_failure
+ script: exit 0
+ needs: [test]
+
+init:
+ expect:
+ pipeline: created
+ stages:
+ test: skipped
+ deploy: created
+ jobs:
+ test: manual
+ deploy: created
+
+transitions:
+ - event: enqueue
+ jobs: [test]
+ expect:
+ pipeline: pending
+ stages:
+ test: pending
+ deploy: created
+ jobs:
+ test: pending
+ deploy: created
+
+ - event: drop
+ jobs: [test]
+ expect:
+ pipeline: success
+ stages:
+ test: success
+ deploy: skipped
+ jobs:
+ test: failed
+ deploy: skipped
+
+# TODO: should we run `deploy`?
+# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_other_test_succeeds.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_other_test_succeeds.yml
new file mode 100644
index 00000000000..34073b92ccc
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_other_test_succeeds.yml
@@ -0,0 +1,42 @@
+config:
+ test1:
+ stage: test
+ script: exit 0
+
+ test2:
+ stage: test
+ when: manual
+ allow_failure: true
+ script: exit 1
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ needs: [test1, test2]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ test: pending
+ deploy: created
+ jobs:
+ test1: pending
+ test2: manual
+ deploy: created
+
+transitions:
+ - event: success
+ jobs: [test1]
+ expect:
+ pipeline: running
+ stages:
+ test: success
+ deploy: created
+ jobs:
+ test1: success
+ test2: manual
+ deploy: created
+
+# TODO: should deploy run?
+# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_on_failure_with_failure.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_on_failure_with_failure.yml
new file mode 100644
index 00000000000..5ace621e89c
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_on_failure_with_failure.yml
@@ -0,0 +1,66 @@
+config:
+ build:
+ stage: build
+ script: exit 1
+
+ test:
+ stage: test
+ when: on_failure
+ script: exit 0
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ needs: [test]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: drop
+ jobs: [build]
+ expect:
+ pipeline: running
+ stages:
+ build: failed
+ test: pending
+ deploy: created
+ jobs:
+ build: failed
+ test: pending
+ deploy: created
+
+ - event: success
+ jobs: [test]
+ expect:
+ pipeline: running
+ stages:
+ build: failed
+ test: success
+ deploy: pending
+ jobs:
+ build: failed
+ test: success
+ deploy: pending
+
+ - event: success
+ jobs: [deploy]
+ expect:
+ pipeline: failed
+ stages:
+ build: failed
+ test: success
+ deploy: success
+ jobs:
+ build: failed
+ test: success
+ deploy: success
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_on_failure_with_success.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_on_failure_with_success.yml
new file mode 100644
index 00000000000..19524cfd3e4
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_on_failure_with_success.yml
@@ -0,0 +1,40 @@
+config:
+ build:
+ stage: build
+ script: exit 0
+
+ test:
+ stage: test
+ when: on_failure
+ script: exit 0
+
+ deploy:
+ stage: deploy
+ script: exit 0
+ needs: [test]
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: success
+ jobs: [build]
+ expect:
+ pipeline: success
+ stages:
+ build: success
+ test: skipped
+ deploy: skipped
+ jobs:
+ build: success
+ test: skipped
+ deploy: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/stage_build_allow_failure_test_on_failure.yml b/spec/services/ci/pipeline_processing/test_cases/stage_build_allow_failure_test_on_failure.yml
new file mode 100644
index 00000000000..3e081d4411b
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/stage_build_allow_failure_test_on_failure.yml
@@ -0,0 +1,53 @@
+config:
+ build:
+ stage: build
+ allow_failure: true
+ script: exit 1
+
+ test:
+ stage: test
+ when: on_failure
+ script: exit 0
+
+ deploy:
+ stage: deploy
+ script: exit 0
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: drop
+ jobs: [build]
+ expect:
+ pipeline: pending
+ stages:
+ build: success
+ test: skipped
+ deploy: pending
+ jobs:
+ build: failed
+ test: skipped
+ deploy: pending
+
+ - event: success
+ jobs: [deploy]
+ expect:
+ pipeline: success
+ stages:
+ build: success
+ test: skipped
+ deploy: success
+ jobs:
+ build: failed
+ test: skipped
+ deploy: success
diff --git a/spec/services/ci/pipeline_processing/test_cases/stage_build_fails.yml b/spec/services/ci/pipeline_processing/test_cases/stage_build_fails.yml
new file mode 100644
index 00000000000..0618abf3524
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/stage_build_fails.yml
@@ -0,0 +1,38 @@
+config:
+ build:
+ stage: build
+ script: exit 1
+
+ test:
+ stage: test
+ script: exit 0
+
+ deploy:
+ stage: deploy
+ script: exit 0
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: drop
+ jobs: [build]
+ expect:
+ pipeline: failed
+ stages:
+ build: failed
+ test: skipped
+ deploy: skipped
+ jobs:
+ build: failed
+ test: skipped
+ deploy: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/stage_build_fails_test_allow_failure.yml b/spec/services/ci/pipeline_processing/test_cases/stage_build_fails_test_allow_failure.yml
new file mode 100644
index 00000000000..362ac6e4239
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/stage_build_fails_test_allow_failure.yml
@@ -0,0 +1,39 @@
+config:
+ build:
+ stage: build
+ script: exit 1
+
+ test:
+ stage: test
+ allow_failure: true
+ script: exit 1
+
+ deploy:
+ stage: deploy
+ script: exit 0
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: drop
+ jobs: [build]
+ expect:
+ pipeline: failed
+ stages:
+ build: failed
+ test: skipped
+ deploy: skipped
+ jobs:
+ build: failed
+ test: skipped
+ deploy: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/stage_test_manual_allow_failure_false.yml b/spec/services/ci/pipeline_processing/test_cases/stage_test_manual_allow_failure_false.yml
new file mode 100644
index 00000000000..2ffa35b56d7
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/stage_test_manual_allow_failure_false.yml
@@ -0,0 +1,65 @@
+config:
+ test:
+ stage: test
+ when: manual
+ allow_failure: false
+ script: exit 0
+
+ deploy:
+ stage: deploy
+ script: exit 0
+
+init:
+ expect:
+ pipeline: manual
+ stages:
+ test: manual
+ deploy: created
+ jobs:
+ test: manual
+ deploy: created
+
+transitions:
+ - event: enqueue
+ jobs: [test]
+ expect:
+ pipeline: pending
+ stages:
+ test: pending
+ deploy: created
+ jobs:
+ test: pending
+ deploy: created
+
+ - event: run
+ jobs: [test]
+ expect:
+ pipeline: running
+ stages:
+ test: running
+ deploy: created
+ jobs:
+ test: running
+ deploy: created
+
+ - event: success
+ jobs: [test]
+ expect:
+ pipeline: running
+ stages:
+ test: success
+ deploy: pending
+ jobs:
+ test: success
+ deploy: pending
+
+ - event: success
+ jobs: [deploy]
+ expect:
+ pipeline: success
+ stages:
+ test: success
+ deploy: success
+ jobs:
+ test: success
+ deploy: success
diff --git a/spec/services/ci/pipeline_processing/test_cases/stage_test_manual_allow_failure_true.yml b/spec/services/ci/pipeline_processing/test_cases/stage_test_manual_allow_failure_true.yml
new file mode 100644
index 00000000000..088fab5ca09
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/stage_test_manual_allow_failure_true.yml
@@ -0,0 +1,54 @@
+config:
+ test:
+ stage: test
+ when: manual
+ allow_failure: true
+ script: exit 1
+
+ deploy:
+ stage: deploy
+ script: exit 0
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ test: skipped
+ deploy: pending
+ jobs:
+ test: manual
+ deploy: pending
+
+transitions:
+ - event: success
+ jobs: [deploy]
+ expect:
+ pipeline: success
+ stages:
+ test: skipped
+ deploy: success
+ jobs:
+ test: manual
+ deploy: success
+
+ - event: enqueue
+ jobs: [test]
+ expect:
+ pipeline: running
+ stages:
+ test: pending
+ deploy: success
+ jobs:
+ test: pending
+ deploy: success
+
+ - event: drop
+ jobs: [test]
+ expect:
+ pipeline: success
+ stages:
+ test: success
+ deploy: success
+ jobs:
+ test: failed
+ deploy: success
diff --git a/spec/services/ci/pipeline_processing/test_cases/stage_test_manual_allow_failure_true_deploy_on_failure.yml b/spec/services/ci/pipeline_processing/test_cases/stage_test_manual_allow_failure_true_deploy_on_failure.yml
new file mode 100644
index 00000000000..2b30316aef6
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/stage_test_manual_allow_failure_true_deploy_on_failure.yml
@@ -0,0 +1,44 @@
+config:
+ test:
+ stage: test
+ when: manual
+ allow_failure: true
+ script: exit 1
+
+ deploy:
+ stage: deploy
+ when: on_failure
+ script: exit 0
+
+init:
+ expect:
+ pipeline: skipped
+ stages:
+ test: skipped
+ deploy: skipped
+ jobs:
+ test: manual
+ deploy: skipped
+
+transitions:
+ - event: enqueue
+ jobs: [test]
+ expect:
+ pipeline: pending
+ stages:
+ test: pending
+ deploy: skipped
+ jobs:
+ test: pending
+ deploy: skipped
+
+ - event: drop
+ jobs: [test]
+ expect:
+ pipeline: success
+ stages:
+ test: success
+ deploy: skipped
+ jobs:
+ test: failed
+ deploy: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/stage_test_on_failure_with_failure.yml b/spec/services/ci/pipeline_processing/test_cases/stage_test_on_failure_with_failure.yml
new file mode 100644
index 00000000000..1751cbb2023
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/stage_test_on_failure_with_failure.yml
@@ -0,0 +1,52 @@
+config:
+ build:
+ stage: build
+ script: exit 1
+
+ test:
+ stage: test
+ when: on_failure
+ script: exit 0
+
+ deploy:
+ stage: deploy
+ script: exit 0
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: drop
+ jobs: [build]
+ expect:
+ pipeline: running
+ stages:
+ build: failed
+ test: pending
+ deploy: created
+ jobs:
+ build: failed
+ test: pending
+ deploy: created
+
+ - event: success
+ jobs: [test]
+ expect:
+ pipeline: failed
+ stages:
+ build: failed
+ test: success
+ deploy: skipped
+ jobs:
+ build: failed
+ test: success
+ deploy: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/stage_test_on_failure_with_success.yml b/spec/services/ci/pipeline_processing/test_cases/stage_test_on_failure_with_success.yml
new file mode 100644
index 00000000000..15afe1ce8e1
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/stage_test_on_failure_with_success.yml
@@ -0,0 +1,52 @@
+config:
+ build:
+ stage: build
+ script: exit 0
+
+ test:
+ stage: test
+ when: on_failure
+ script: exit 0
+
+ deploy:
+ stage: deploy
+ script: exit 0
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: created
+ deploy: created
+ jobs:
+ build: pending
+ test: created
+ deploy: created
+
+transitions:
+ - event: success
+ jobs: [build]
+ expect:
+ pipeline: running
+ stages:
+ build: success
+ test: skipped
+ deploy: pending
+ jobs:
+ build: success
+ test: skipped
+ deploy: pending
+
+ - event: success
+ jobs: [deploy]
+ expect:
+ pipeline: success
+ stages:
+ build: success
+ test: skipped
+ deploy: success
+ jobs:
+ build: success
+ test: skipped
+ deploy: success
diff --git a/spec/services/ci/pipeline_schedule_service_spec.rb b/spec/services/ci/pipeline_schedule_service_spec.rb
index f7590720f66..867ed0acc0d 100644
--- a/spec/services/ci/pipeline_schedule_service_spec.rb
+++ b/spec/services/ci/pipeline_schedule_service_spec.rb
@@ -25,38 +25,6 @@ describe Ci::PipelineScheduleService do
subject
end
- context 'when ci_pipeline_schedule_async feature flag is disabled' do
- before do
- stub_feature_flags(ci_pipeline_schedule_async: false)
- end
-
- it 'runs RunPipelineScheduleWorker synchronously' do
- expect_next_instance_of(RunPipelineScheduleWorker) do |worker|
- expect(worker).to receive(:perform).with(schedule.id, schedule.owner.id)
- end
-
- subject
- end
-
- it 'calls Garbage Collection manually' do
- expect(GC).to receive(:start)
-
- subject
- end
-
- context 'when ci_pipeline_schedule_force_gc feature flag is disabled' do
- before do
- stub_feature_flags(ci_pipeline_schedule_force_gc: false)
- end
-
- it 'does not call Garbage Collection manually' do
- expect(GC).not_to receive(:start)
-
- subject
- end
- end
- end
-
context 'when owner is nil' do
let(:schedule) { create(:ci_pipeline_schedule, project: project, owner: nil) }
diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb
index 6f5a070d73d..40ae1c4029b 100644
--- a/spec/services/ci/process_pipeline_service_spec.rb
+++ b/spec/services/ci/process_pipeline_service_spec.rb
@@ -33,25 +33,6 @@ describe Ci::ProcessPipelineService do
end
end
- context 'with a pipeline which has processables with nil scheduling_type', :clean_gitlab_redis_shared_state do
- let!(:build1) { create_build('build1') }
- let!(:build2) { create_build('build2') }
- let!(:build3) { create_build('build3', scheduling_type: :dag) }
- let!(:build3_on_build2) { create(:ci_build_need, build: build3, name: 'build2') }
-
- before do
- pipeline.processables.update_all(scheduling_type: nil)
- end
-
- it 'populates scheduling_type before processing' do
- process_pipeline
-
- expect(build1.scheduling_type).to eq('stage')
- expect(build2.scheduling_type).to eq('stage')
- expect(build3.scheduling_type).to eq('dag')
- end
- end
-
def process_pipeline
described_class.new(pipeline).execute
end
diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb
index 2da1350e2af..c0f854df9b7 100644
--- a/spec/services/ci/register_job_service_spec.rb
+++ b/spec/services/ci/register_job_service_spec.rb
@@ -571,7 +571,7 @@ module Ci
end
describe '#register_success' do
- let!(:current_time) { Time.new(2018, 4, 5, 14, 0, 0) }
+ let!(:current_time) { Time.zone.local(2018, 4, 5, 14, 0, 0) }
let!(:attempt_counter) { double('Gitlab::Metrics::NullMetric') }
let!(:job_queue_duration_seconds) { double('Gitlab::Metrics::NullMetric') }
diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb
index 86b68dc3ade..0aa603b24ae 100644
--- a/spec/services/ci/retry_build_service_spec.rb
+++ b/spec/services/ci/retry_build_service_spec.rb
@@ -22,9 +22,9 @@ describe Ci::RetryBuildService do
described_class.new(project, user)
end
- CLONE_ACCESSORS = described_class::CLONE_ACCESSORS
+ clone_accessors = described_class::CLONE_ACCESSORS
- REJECT_ACCESSORS =
+ reject_accessors =
%i[id status user token token_encrypted coverage trace runner
artifacts_expire_at
created_at updated_at started_at finished_at queued_at erased_by
@@ -34,13 +34,13 @@ describe Ci::RetryBuildService do
job_artifacts_container_scanning job_artifacts_dast
job_artifacts_license_management job_artifacts_license_scanning
job_artifacts_performance job_artifacts_lsif
- job_artifacts_terraform
+ job_artifacts_terraform job_artifacts_cluster_applications
job_artifacts_codequality job_artifacts_metrics scheduled_at
job_variables waiting_for_resource_at job_artifacts_metrics_referee
job_artifacts_network_referee job_artifacts_dotenv
- job_artifacts_cobertura needs].freeze
+ job_artifacts_cobertura needs job_artifacts_accessibility].freeze
- IGNORE_ACCESSORS =
+ ignore_accessors =
%i[type lock_version target_url base_tags trace_sections
commit_id deployment erased_by_id project_id
runner_id tag_taggings taggings tags trigger_request_id
@@ -63,6 +63,9 @@ describe Ci::RetryBuildService do
end
before do
+ # Test correctly behaviour of deprecated artifact because it can be still in use
+ stub_feature_flags(drop_license_management_artifact: false)
+
# 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.
@@ -88,7 +91,7 @@ describe Ci::RetryBuildService do
end
end
- CLONE_ACCESSORS.each do |attribute|
+ clone_accessors.each do |attribute|
it "clones #{attribute} build attribute" do
expect(attribute).not_to be_in(forbidden_associations), "association #{attribute} must be `belongs_to`"
expect(build.send(attribute)).not_to be_nil
@@ -118,7 +121,7 @@ describe Ci::RetryBuildService do
end
describe 'reject accessors' do
- REJECT_ACCESSORS.each do |attribute|
+ reject_accessors.each do |attribute|
it "does not clone #{attribute} build attribute" do
expect(new_build.send(attribute)).not_to eq build.send(attribute)
end
@@ -126,8 +129,8 @@ describe Ci::RetryBuildService do
end
it 'has correct number of known attributes' do
- processed_accessors = CLONE_ACCESSORS + REJECT_ACCESSORS
- known_accessors = processed_accessors + IGNORE_ACCESSORS
+ processed_accessors = clone_accessors + reject_accessors
+ known_accessors = processed_accessors + ignore_accessors
# :tag_list is a special case, this accessor does not exist
# in reflected associations, comes from `act_as_taggable` and
@@ -190,6 +193,35 @@ describe Ci::RetryBuildService do
expect(subsequent_build.reload).to be_created
end
end
+
+ context 'when pipeline has other builds' do
+ let!(:stage2) { create(:ci_stage_entity, project: project, pipeline: pipeline, name: 'deploy') }
+ let!(:build2) { create(:ci_build, pipeline: pipeline, stage_id: stage.id ) }
+ let!(:deploy) { create(:ci_build, pipeline: pipeline, stage_id: stage2.id) }
+ let!(:deploy_needs_build2) { create(:ci_build_need, build: deploy, name: build2.name) }
+
+ context 'when build has nil scheduling_type' do
+ before do
+ build.pipeline.processables.update_all(scheduling_type: nil)
+ build.reload
+ end
+
+ it 'populates scheduling_type of processables' do
+ expect(new_build.scheduling_type).to eq('stage')
+ expect(build.reload.scheduling_type).to eq('stage')
+ expect(build2.reload.scheduling_type).to eq('stage')
+ expect(deploy.reload.scheduling_type).to eq('dag')
+ end
+ end
+
+ context 'when build has scheduling_type' do
+ it 'does not call populate_scheduling_type!' do
+ expect_any_instance_of(Ci::Pipeline).not_to receive(:ensure_scheduling_type!)
+
+ expect(new_build.scheduling_type).to eq('stage')
+ end
+ 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 81a0b05f2c7..8e85e68d4fc 100644
--- a/spec/services/ci/retry_pipeline_service_spec.rb
+++ b/spec/services/ci/retry_pipeline_service_spec.rb
@@ -261,6 +261,25 @@ describe Ci::RetryPipelineService, '#execute' do
service.execute(pipeline)
end
+
+ context 'when pipeline has processables with nil scheduling_type' do
+ let!(:build1) { create_build('build1', :success, 0) }
+ let!(:build2) { create_build('build2', :failed, 0) }
+ let!(:build3) { create_build('build3', :failed, 1) }
+ let!(:build3_needs_build1) { create(:ci_build_need, build: build3, name: build1.name) }
+
+ before do
+ statuses.update_all(scheduling_type: nil)
+ end
+
+ it 'populates scheduling_type of processables' do
+ service.execute(pipeline)
+
+ expect(build1.reload.scheduling_type).to eq('stage')
+ expect(build2.reload.scheduling_type).to eq('stage')
+ expect(build3.reload.scheduling_type).to eq('dag')
+ end
+ end
end
context 'when user is not allowed to retry pipeline' do
diff --git a/spec/services/ci/update_instance_variables_service_spec.rb b/spec/services/ci/update_instance_variables_service_spec.rb
new file mode 100644
index 00000000000..93f6e5d3ea8
--- /dev/null
+++ b/spec/services/ci/update_instance_variables_service_spec.rb
@@ -0,0 +1,230 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::UpdateInstanceVariablesService do
+ let(:params) { { variables_attributes: variables_attributes } }
+
+ subject { described_class.new(params) }
+
+ describe '#execute' do
+ context 'without variables' do
+ let(:variables_attributes) { [] }
+
+ it { expect(subject.execute).to be_truthy }
+ end
+
+ context 'with insert only variables' do
+ let(:variables_attributes) do
+ [
+ { key: 'var_a', secret_value: 'dummy_value_for_a', protected: true },
+ { key: 'var_b', secret_value: 'dummy_value_for_b', protected: false }
+ ]
+ end
+
+ it { expect(subject.execute).to be_truthy }
+
+ it 'persists all the records' do
+ expect { subject.execute }
+ .to change { Ci::InstanceVariable.count }
+ .by variables_attributes.size
+ end
+
+ it 'persists attributes' do
+ subject.execute
+
+ expect(Ci::InstanceVariable.all).to contain_exactly(
+ have_attributes(key: 'var_a', secret_value: 'dummy_value_for_a', protected: true),
+ have_attributes(key: 'var_b', secret_value: 'dummy_value_for_b', protected: false)
+ )
+ end
+ end
+
+ context 'with update only variables' do
+ let!(:var_a) { create(:ci_instance_variable) }
+ let!(:var_b) { create(:ci_instance_variable, protected: false) }
+
+ let(:variables_attributes) do
+ [
+ {
+ id: var_a.id,
+ key: var_a.key,
+ secret_value: 'new_dummy_value_for_a',
+ protected: var_a.protected?.to_s
+ },
+ {
+ id: var_b.id,
+ key: 'var_b_key',
+ secret_value: 'new_dummy_value_for_b',
+ protected: 'true'
+ }
+ ]
+ end
+
+ it { expect(subject.execute).to be_truthy }
+
+ it 'does not change the count' do
+ expect { subject.execute }
+ .not_to change { Ci::InstanceVariable.count }
+ end
+
+ it 'updates the records in place', :aggregate_failures do
+ subject.execute
+
+ expect(var_a.reload).to have_attributes(secret_value: 'new_dummy_value_for_a')
+
+ expect(var_b.reload).to have_attributes(
+ key: 'var_b_key', secret_value: 'new_dummy_value_for_b', protected: true)
+ end
+ end
+
+ context 'with insert and update variables' do
+ let!(:var_a) { create(:ci_instance_variable) }
+
+ let(:variables_attributes) do
+ [
+ {
+ id: var_a.id,
+ key: var_a.key,
+ secret_value: 'new_dummy_value_for_a',
+ protected: var_a.protected?.to_s
+ },
+ {
+ key: 'var_b',
+ secret_value: 'dummy_value_for_b',
+ protected: true
+ }
+ ]
+ end
+
+ it { expect(subject.execute).to be_truthy }
+
+ it 'inserts only one record' do
+ expect { subject.execute }
+ .to change { Ci::InstanceVariable.count }.by 1
+ end
+
+ it 'persists all the records', :aggregate_failures do
+ subject.execute
+ var_b = Ci::InstanceVariable.find_by(key: 'var_b')
+
+ expect(var_a.reload.secret_value).to eq('new_dummy_value_for_a')
+ expect(var_b.secret_value).to eq('dummy_value_for_b')
+ end
+ end
+
+ context 'with insert, update, and destroy variables' do
+ let!(:var_a) { create(:ci_instance_variable) }
+ let!(:var_b) { create(:ci_instance_variable) }
+
+ let(:variables_attributes) do
+ [
+ {
+ id: var_a.id,
+ key: var_a.key,
+ secret_value: 'new_dummy_value_for_a',
+ protected: var_a.protected?.to_s
+ },
+ {
+ id: var_b.id,
+ key: var_b.key,
+ secret_value: 'dummy_value_for_b',
+ protected: var_b.protected?.to_s,
+ '_destroy' => 'true'
+ },
+ {
+ key: 'var_c',
+ secret_value: 'dummy_value_for_c',
+ protected: true
+ }
+ ]
+ end
+
+ it { expect(subject.execute).to be_truthy }
+
+ it 'persists all the records', :aggregate_failures do
+ subject.execute
+ var_c = Ci::InstanceVariable.find_by(key: 'var_c')
+
+ expect(var_a.reload.secret_value).to eq('new_dummy_value_for_a')
+ expect { var_b.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect(var_c.secret_value).to eq('dummy_value_for_c')
+ end
+ end
+
+ context 'with invalid variables' do
+ let!(:var_a) { create(:ci_instance_variable, secret_value: 'dummy_value_for_a') }
+
+ let(:variables_attributes) do
+ [
+ {
+ key: '...?',
+ secret_value: 'nice_value'
+ },
+ {
+ id: var_a.id,
+ key: var_a.key,
+ secret_value: 'new_dummy_value_for_a',
+ protected: var_a.protected?.to_s
+ },
+ {
+ key: var_a.key,
+ secret_value: 'other_value'
+ }
+ ]
+ end
+
+ it { expect(subject.execute).to be_falsey }
+
+ it 'does not insert any records' do
+ expect { subject.execute }
+ .not_to change { Ci::InstanceVariable.count }
+ end
+
+ it 'does not update existing records' do
+ subject.execute
+
+ expect(var_a.reload.secret_value).to eq('dummy_value_for_a')
+ end
+
+ it 'returns errors' do
+ subject.execute
+
+ expect(subject.errors).to match_array(
+ [
+ "Key (#{var_a.key}) has already been taken",
+ "Key can contain only letters, digits and '_'."
+ ])
+ end
+ end
+
+ context 'when deleting non existing variables' do
+ let(:variables_attributes) do
+ [
+ {
+ id: 'some-id',
+ key: 'some_key',
+ secret_value: 'other_value',
+ '_destroy' => 'true'
+ }
+ ]
+ end
+
+ it { expect { subject.execute }.to raise_error(ActiveRecord::RecordNotFound) }
+ end
+
+ context 'when updating non existing variables' do
+ let(:variables_attributes) do
+ [
+ {
+ id: 'some-id',
+ key: 'some_key',
+ secret_value: 'other_value'
+ }
+ ]
+ end
+
+ it { expect { subject.execute }.to raise_error(ActiveRecord::RecordNotFound) }
+ end
+ end
+end
diff --git a/spec/services/clusters/applications/check_upgrade_progress_service_spec.rb b/spec/services/clusters/applications/check_upgrade_progress_service_spec.rb
index c08b618fe6a..29ee897454a 100644
--- a/spec/services/clusters/applications/check_upgrade_progress_service_spec.rb
+++ b/spec/services/clusters/applications/check_upgrade_progress_service_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
describe Clusters::Applications::CheckUpgradeProgressService do
- RESCHEDULE_PHASES = ::Gitlab::Kubernetes::Pod::PHASES -
+ reschedule_phashes = ::Gitlab::Kubernetes::Pod::PHASES -
[::Gitlab::Kubernetes::Pod::SUCCEEDED, ::Gitlab::Kubernetes::Pod::FAILED, ::Gitlab].freeze
let(:application) { create(:clusters_applications_prometheus, :updating) }
@@ -89,6 +89,6 @@ describe Clusters::Applications::CheckUpgradeProgressService do
end
end
- RESCHEDULE_PHASES.each { |phase| it_behaves_like 'a not yet terminated upgrade', phase }
+ reschedule_phashes.each { |phase| it_behaves_like 'a not yet terminated upgrade', phase }
end
end
diff --git a/spec/services/clusters/applications/ingress_modsecurity_usage_service_spec.rb b/spec/services/clusters/applications/ingress_modsecurity_usage_service_spec.rb
deleted file mode 100644
index d456284f76a..00000000000
--- a/spec/services/clusters/applications/ingress_modsecurity_usage_service_spec.rb
+++ /dev/null
@@ -1,196 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe Clusters::Applications::IngressModsecurityUsageService do
- describe '#execute' do
- ADO_MODSEC_KEY = Clusters::Applications::IngressModsecurityUsageService::ADO_MODSEC_KEY
-
- let(:project_with_ci_var) { create(:environment).project }
- let(:project_with_pipeline_var) { create(:environment).project }
-
- subject { described_class.new.execute }
-
- context 'with multiple projects' do
- let(:pipeline1) { create(:ci_pipeline, :with_job, project: project_with_pipeline_var) }
- let(:pipeline2) { create(:ci_pipeline, :with_job, project: project_with_ci_var) }
-
- let!(:deployment_with_pipeline_var) do
- create(
- :deployment,
- :success,
- environment: project_with_pipeline_var.environments.first,
- project: project_with_pipeline_var,
- deployable: pipeline1.builds.last
- )
- end
- let!(:deployment_with_project_var) do
- create(
- :deployment,
- :success,
- environment: project_with_ci_var.environments.first,
- project: project_with_ci_var,
- deployable: pipeline2.builds.last
- )
- end
-
- context 'mixed data' do
- let!(:ci_variable) { create(:ci_variable, project: project_with_ci_var, key: ADO_MODSEC_KEY, value: "On") }
- let!(:pipeline_variable) { create(:ci_pipeline_variable, pipeline: pipeline1, key: ADO_MODSEC_KEY, value: "Off") }
-
- it 'gathers variable data' do
- expect(subject[:ingress_modsecurity_blocking]).to eq(1)
- expect(subject[:ingress_modsecurity_disabled]).to eq(1)
- end
- end
-
- context 'blocking' do
- let(:modsec_values) { { key: ADO_MODSEC_KEY, value: "On" } }
-
- let!(:ci_variable) { create(:ci_variable, project: project_with_ci_var, **modsec_values) }
- let!(:pipeline_variable) { create(:ci_pipeline_variable, pipeline: pipeline1, **modsec_values) }
-
- it 'gathers variable data' do
- expect(subject[:ingress_modsecurity_blocking]).to eq(2)
- expect(subject[:ingress_modsecurity_disabled]).to eq(0)
- end
- end
-
- context 'disabled' do
- let(:modsec_values) { { key: ADO_MODSEC_KEY, value: "Off" } }
-
- let!(:ci_variable) { create(:ci_variable, project: project_with_ci_var, **modsec_values) }
- let!(:pipeline_variable) { create(:ci_pipeline_variable, pipeline: pipeline1, **modsec_values) }
-
- it 'gathers variable data' do
- expect(subject[:ingress_modsecurity_blocking]).to eq(0)
- expect(subject[:ingress_modsecurity_disabled]).to eq(2)
- end
- end
- end
-
- context 'when set as both ci and pipeline variables' do
- let(:modsec_values) { { key: ADO_MODSEC_KEY, value: "Off" } }
-
- let(:pipeline) { create(:ci_pipeline, :with_job, project: project_with_ci_var) }
- let!(:deployment) do
- create(
- :deployment,
- :success,
- environment: project_with_ci_var.environments.first,
- project: project_with_ci_var,
- deployable: pipeline.builds.last
- )
- end
-
- let!(:ci_variable) { create(:ci_variable, project: project_with_ci_var, **modsec_values) }
- let!(:pipeline_variable) { create(:ci_pipeline_variable, pipeline: pipeline, **modsec_values) }
-
- it 'wont double-count projects' do
- expect(subject[:ingress_modsecurity_blocking]).to eq(0)
- expect(subject[:ingress_modsecurity_disabled]).to eq(1)
- end
-
- it 'gives precedence to pipeline variable' do
- pipeline_variable.update(value: "On")
-
- expect(subject[:ingress_modsecurity_blocking]).to eq(1)
- expect(subject[:ingress_modsecurity_disabled]).to eq(0)
- end
- end
-
- context 'when a project has multiple environments' do
- let(:modsec_values) { { key: ADO_MODSEC_KEY, value: "On" } }
-
- let!(:env1) { project_with_pipeline_var.environments.first }
- let!(:env2) { create(:environment, project: project_with_pipeline_var) }
-
- let!(:pipeline_with_2_deployments) do
- create(:ci_pipeline, :with_job, project: project_with_ci_var).tap do |pip|
- pip.builds << build(:ci_build, pipeline: pip, project: project_with_pipeline_var)
- end
- end
-
- let!(:deployment1) do
- create(
- :deployment,
- :success,
- environment: env1,
- project: project_with_pipeline_var,
- deployable: pipeline_with_2_deployments.builds.last
- )
- end
- let!(:deployment2) do
- create(
- :deployment,
- :success,
- environment: env2,
- project: project_with_pipeline_var,
- deployable: pipeline_with_2_deployments.builds.last
- )
- end
-
- context 'when set as ci variable' do
- let!(:ci_variable) { create(:ci_variable, project: project_with_pipeline_var, **modsec_values) }
-
- it 'gathers variable data' do
- expect(subject[:ingress_modsecurity_blocking]).to eq(2)
- expect(subject[:ingress_modsecurity_disabled]).to eq(0)
- end
- end
-
- context 'when set as pipeline variable' do
- let!(:pipeline_variable) { create(:ci_pipeline_variable, pipeline: pipeline_with_2_deployments, **modsec_values) }
-
- it 'gathers variable data' do
- expect(subject[:ingress_modsecurity_blocking]).to eq(2)
- expect(subject[:ingress_modsecurity_disabled]).to eq(0)
- end
- end
- end
-
- context 'when an environment has multiple deployments' do
- let!(:env) { project_with_pipeline_var.environments.first }
-
- let!(:pipeline_first) do
- create(:ci_pipeline, :with_job, project: project_with_pipeline_var).tap do |pip|
- pip.builds << build(:ci_build, pipeline: pip, project: project_with_pipeline_var)
- end
- end
- let!(:pipeline_last) do
- create(:ci_pipeline, :with_job, project: project_with_pipeline_var).tap do |pip|
- pip.builds << build(:ci_build, pipeline: pip, project: project_with_pipeline_var)
- end
- end
-
- let!(:deployment_first) do
- create(
- :deployment,
- :success,
- environment: env,
- project: project_with_pipeline_var,
- deployable: pipeline_first.builds.last
- )
- end
- let!(:deployment_last) do
- create(
- :deployment,
- :success,
- environment: env,
- project: project_with_pipeline_var,
- deployable: pipeline_last.builds.last
- )
- end
-
- context 'when set as pipeline variable' do
- let!(:first_pipeline_variable) { create(:ci_pipeline_variable, pipeline: pipeline_first, key: ADO_MODSEC_KEY, value: "On") }
- let!(:last_pipeline_variable) { create(:ci_pipeline_variable, pipeline: pipeline_last, key: ADO_MODSEC_KEY, value: "Off") }
-
- it 'gives precedence to latest deployment' do
- expect(subject[:ingress_modsecurity_blocking]).to eq(0)
- expect(subject[:ingress_modsecurity_disabled]).to eq(1)
- end
- end
- 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 0764f5b6a97..eb1006ce8e0 100644
--- a/spec/services/clusters/applications/schedule_update_service_spec.rb
+++ b/spec/services/clusters/applications/schedule_update_service_spec.rb
@@ -13,10 +13,10 @@ describe Clusters::Applications::ScheduleUpdateService do
context 'when application is able to be updated' do
context 'when the application was recently scheduled' do
it 'schedules worker with a backoff delay' do
- application = create(:clusters_applications_prometheus, :installed, last_update_started_at: Time.now + 5.minutes)
+ application = create(:clusters_applications_prometheus, :installed, last_update_started_at: Time.current + 5.minutes)
service = described_class.new(application, project)
- expect(::ClusterUpdateAppWorker).to receive(:perform_in).with(described_class::BACKOFF_DELAY, application.name, application.id, project.id, Time.now).once
+ expect(::ClusterUpdateAppWorker).to receive(:perform_in).with(described_class::BACKOFF_DELAY, application.name, application.id, project.id, Time.current).once
service.execute
end
@@ -27,7 +27,7 @@ describe Clusters::Applications::ScheduleUpdateService do
application = create(:clusters_applications_prometheus, :installed)
service = described_class.new(application, project)
- expect(::ClusterUpdateAppWorker).to receive(:perform_async).with(application.name, application.id, project.id, Time.now).once
+ expect(::ClusterUpdateAppWorker).to receive(:perform_async).with(application.name, application.id, project.id, Time.current).once
service.execute
end
diff --git a/spec/services/clusters/gcp/finalize_creation_service_spec.rb b/spec/services/clusters/gcp/finalize_creation_service_spec.rb
index 43dbea959a2..4d1548c9786 100644
--- a/spec/services/clusters/gcp/finalize_creation_service_spec.rb
+++ b/spec/services/clusters/gcp/finalize_creation_service_spec.rb
@@ -108,8 +108,7 @@ describe Clusters::Gcp::FinalizeCreationService, '#execute' do
}
)
- stub_kubeclient_get_cluster_role_binding_error(api_url, 'gitlab-admin')
- stub_kubeclient_create_cluster_role_binding(api_url)
+ stub_kubeclient_put_cluster_role_binding(api_url, 'gitlab-admin')
end
end
diff --git a/spec/services/clusters/kubernetes/configure_istio_ingress_service_spec.rb b/spec/services/clusters/kubernetes/configure_istio_ingress_service_spec.rb
index 9238f7debd0..e9f7f015293 100644
--- a/spec/services/clusters/kubernetes/configure_istio_ingress_service_spec.rb
+++ b/spec/services/clusters/kubernetes/configure_istio_ingress_service_spec.rb
@@ -120,8 +120,8 @@ describe Clusters::Kubernetes::ConfigureIstioIngressService, '#execute' do
expect(certificate.subject.to_s).to include(serverless_domain_cluster.knative.hostname)
- expect(certificate.not_before).to be_within(1.minute).of(Time.now)
- expect(certificate.not_after).to be_within(1.minute).of(Time.now + 1000.years)
+ expect(certificate.not_before).to be_within(1.minute).of(Time.current)
+ expect(certificate.not_after).to be_within(1.minute).of(Time.current + 1000.years)
expect(WebMock).to have_requested(:put, api_url + '/api/v1/namespaces/istio-system/secrets/istio-ingressgateway-ca-certs').with(
body: hash_including(
diff --git a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
index 3982d2310d8..6d8b1617c17 100644
--- a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
+++ b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
@@ -28,7 +28,6 @@ describe Clusters::Kubernetes::CreateOrUpdateNamespaceService, '#execute' do
stub_kubeclient_get_secret_error(api_url, 'gitlab-token')
stub_kubeclient_create_secret(api_url)
- stub_kubeclient_get_role_binding(api_url, "gitlab-#{namespace}", namespace: namespace)
stub_kubeclient_put_role_binding(api_url, "gitlab-#{namespace}", namespace: namespace)
stub_kubeclient_get_namespace(api_url, namespace: namespace)
stub_kubeclient_get_service_account_error(api_url, "#{namespace}-service-account", namespace: namespace)
diff --git a/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb
index 8fa22422074..4bcd5c6933e 100644
--- a/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb
+++ b/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb
@@ -83,8 +83,7 @@ describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do
before do
cluster.platform_kubernetes.rbac!
- stub_kubeclient_get_cluster_role_binding_error(api_url, cluster_role_binding_name)
- stub_kubeclient_create_cluster_role_binding(api_url)
+ stub_kubeclient_put_cluster_role_binding(api_url, cluster_role_binding_name)
end
it_behaves_like 'creates service account and token'
@@ -92,9 +91,8 @@ describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do
it 'creates a cluster role binding with cluster-admin access' do
subject
- expect(WebMock).to have_requested(:post, api_url + "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings").with(
+ expect(WebMock).to have_requested(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/gitlab-admin").with(
body: hash_including(
- kind: 'ClusterRoleBinding',
metadata: { name: 'gitlab-admin' },
roleRef: {
apiGroup: 'rbac.authorization.k8s.io',
@@ -143,8 +141,7 @@ describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do
before do
cluster.platform_kubernetes.rbac!
- stub_kubeclient_get_role_binding_error(api_url, role_binding_name, namespace: namespace)
- stub_kubeclient_create_role_binding(api_url, namespace: namespace)
+ stub_kubeclient_put_role_binding(api_url, role_binding_name, namespace: namespace)
stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_KNATIVE_SERVING_ROLE_NAME, namespace: namespace)
stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME, namespace: namespace)
stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, namespace: namespace)
@@ -166,9 +163,8 @@ describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do
it 'creates a namespaced role binding with edit access' do
subject
- expect(WebMock).to have_requested(:post, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings").with(
+ expect(WebMock).to have_requested(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings/#{role_binding_name}").with(
body: hash_including(
- kind: 'RoleBinding',
metadata: { name: "gitlab-#{namespace}", namespace: "#{namespace}" },
roleRef: {
apiGroup: 'rbac.authorization.k8s.io',
diff --git a/spec/services/clusters/parse_cluster_applications_artifact_service_spec.rb b/spec/services/clusters/parse_cluster_applications_artifact_service_spec.rb
new file mode 100644
index 00000000000..f14c929554a
--- /dev/null
+++ b/spec/services/clusters/parse_cluster_applications_artifact_service_spec.rb
@@ -0,0 +1,200 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Clusters::ParseClusterApplicationsArtifactService do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ describe 'RELEASE_NAMES' do
+ it 'is included in Cluster application names', :aggregate_failures do
+ described_class::RELEASE_NAMES.each do |release_name|
+ expect(Clusters::Cluster::APPLICATIONS).to include(release_name)
+ end
+ end
+ end
+
+ describe '.new' do
+ let(:job) { build(:ci_build) }
+
+ it 'sets the project and current user', :aggregate_failures do
+ service = described_class.new(job, user)
+
+ expect(service.project).to eq(job.project)
+ expect(service.current_user).to eq(user)
+ end
+ end
+
+ describe '#execute' do
+ let_it_be(:cluster, reload: true) { create(:cluster, projects: [project]) }
+ let_it_be(:deployment, reload: true) { create(:deployment, cluster: cluster) }
+
+ let(:job) { deployment.deployable }
+ let(:artifact) { create(:ci_job_artifact, :cluster_applications, job: job) }
+
+ context 'when cluster_applications_artifact feature flag is disabled' do
+ before do
+ stub_feature_flags(cluster_applications_artifact: false)
+ end
+
+ it 'does not call Gitlab::Kubernetes::Helm::Parsers::ListV2 and returns success immediately' do
+ expect(Gitlab::Kubernetes::Helm::Parsers::ListV2).not_to receive(:new)
+
+ result = described_class.new(job, user).execute(artifact)
+
+ expect(result[:status]).to eq(:success)
+ end
+ end
+
+ context 'when cluster_applications_artifact feature flag is enabled for project' do
+ before do
+ stub_feature_flags(cluster_applications_artifact: job.project)
+ end
+
+ it 'calls Gitlab::Kubernetes::Helm::Parsers::ListV2' do
+ expect(Gitlab::Kubernetes::Helm::Parsers::ListV2).to receive(:new).and_call_original
+
+ result = described_class.new(job, user).execute(artifact)
+
+ expect(result[:status]).to eq(:success)
+ end
+
+ context 'artifact is not of cluster_applications type' do
+ let(:artifact) { create(:ci_job_artifact, :archive) }
+ let(:job) { artifact.job }
+
+ it 'raise ArgumentError' do
+ expect do
+ described_class.new(job, user).execute(artifact)
+ end.to raise_error(ArgumentError, 'Artifact is not cluster_applications file type')
+ end
+ end
+
+ context 'artifact exceeds acceptable size' do
+ it 'returns an error' do
+ stub_const("#{described_class}::MAX_ACCEPTABLE_ARTIFACT_SIZE", 1.byte)
+
+ result = described_class.new(job, user).execute(artifact)
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('Cluster_applications artifact too big. Maximum allowable size: 1 Byte')
+ end
+ end
+
+ context 'job has no deployment cluster' do
+ let(:job) { build(:ci_build) }
+
+ it 'returns an error' do
+ result = described_class.new(job, user).execute(artifact)
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('No deployment cluster found for this job')
+ end
+ end
+
+ context 'job has deployment cluster' do
+ context 'current user does not have access to deployment cluster' do
+ let(:other_user) { create(:user) }
+
+ it 'returns an error' do
+ result = described_class.new(job, other_user).execute(artifact)
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('No deployment cluster found for this job')
+ end
+ end
+
+ context 'release is missing' do
+ let(:fixture) { 'spec/fixtures/helm/helm_list_v2_prometheus_missing.json.gz' }
+ let(:file) { fixture_file_upload(Rails.root.join(fixture)) }
+ let(:artifact) { create(:ci_job_artifact, :cluster_applications, job: job, file: file) }
+
+ context 'application does not exist' do
+ it 'does not create or destroy an application' do
+ expect do
+ described_class.new(job, user).execute(artifact)
+ end.not_to change(Clusters::Applications::Prometheus, :count)
+ end
+ end
+
+ context 'application exists' do
+ before do
+ create(:clusters_applications_prometheus, :installed, cluster: cluster)
+ end
+
+ it 'marks the application as uninstalled' do
+ described_class.new(job, user).execute(artifact)
+
+ cluster.application_prometheus.reload
+ expect(cluster.application_prometheus).to be_uninstalled
+ end
+ end
+ end
+
+ context 'release is deployed' do
+ let(:fixture) { 'spec/fixtures/helm/helm_list_v2_prometheus_deployed.json.gz' }
+ let(:file) { fixture_file_upload(Rails.root.join(fixture)) }
+ let(:artifact) { create(:ci_job_artifact, :cluster_applications, job: job, file: file) }
+
+ context 'application does not exist' do
+ it 'creates an application and marks it as installed' do
+ expect do
+ described_class.new(job, user).execute(artifact)
+ end.to change(Clusters::Applications::Prometheus, :count)
+
+ expect(cluster.application_prometheus).to be_persisted
+ expect(cluster.application_prometheus).to be_installed
+ end
+ end
+
+ context 'application exists' do
+ before do
+ create(:clusters_applications_prometheus, :errored, cluster: cluster)
+ end
+
+ it 'marks the application as installed' do
+ described_class.new(job, user).execute(artifact)
+
+ expect(cluster.application_prometheus).to be_installed
+ end
+ end
+ end
+
+ context 'release is failed' do
+ let(:fixture) { 'spec/fixtures/helm/helm_list_v2_prometheus_failed.json.gz' }
+ let(:file) { fixture_file_upload(Rails.root.join(fixture)) }
+ let(:artifact) { create(:ci_job_artifact, :cluster_applications, job: job, file: file) }
+
+ context 'application does not exist' do
+ it 'creates an application and marks it as errored' do
+ expect do
+ described_class.new(job, user).execute(artifact)
+ end.to change(Clusters::Applications::Prometheus, :count)
+
+ expect(cluster.application_prometheus).to be_persisted
+ expect(cluster.application_prometheus).to be_errored
+ expect(cluster.application_prometheus.status_reason).to eq('Helm release failed to install')
+ end
+ end
+
+ context 'application exists' do
+ before do
+ create(:clusters_applications_prometheus, :installed, cluster: cluster)
+ end
+
+ it 'marks the application as errored' do
+ described_class.new(job, user).execute(artifact)
+
+ expect(cluster.application_prometheus).to be_errored
+ expect(cluster.application_prometheus.status_reason).to eq('Helm release failed to install')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/cohorts_service_spec.rb b/spec/services/cohorts_service_spec.rb
index 38f441fbc4d..b2f82a1153c 100644
--- a/spec/services/cohorts_service_spec.rb
+++ b/spec/services/cohorts_service_spec.rb
@@ -13,7 +13,7 @@ describe CohortsService do
6.times do |months_ago|
months_ago_time = (months_ago * 2).months.ago
- create(:user, created_at: months_ago_time, last_activity_on: Time.now)
+ create(:user, created_at: months_ago_time, last_activity_on: Time.current)
create(:user, created_at: months_ago_time, last_activity_on: months_ago_time)
end
diff --git a/spec/services/deployments/older_deployments_drop_service_spec.rb b/spec/services/deployments/older_deployments_drop_service_spec.rb
index 44e9af07e46..4c9bcf90533 100644
--- a/spec/services/deployments/older_deployments_drop_service_spec.rb
+++ b/spec/services/deployments/older_deployments_drop_service_spec.rb
@@ -66,6 +66,43 @@ describe Deployments::OlderDeploymentsDropService do
expect(deployable.reload.failed?).to be_truthy
end
+ context 'when older deployable is a manual job' do
+ let(:older_deployment) { create(:deployment, :created, environment: environment, deployable: build) }
+ let(:build) { create(:ci_build, :manual) }
+
+ it 'does not drop any builds nor track the exception' do
+ expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
+
+ expect { subject }.not_to change { Ci::Build.failed.count }
+ end
+ end
+
+ context 'when deployable.drop raises RuntimeError' do
+ before do
+ allow_any_instance_of(Ci::Build).to receive(:drop).and_raise(RuntimeError)
+ end
+
+ it 'does not drop an older deployment and tracks the exception' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(kind_of(RuntimeError), subject_id: deployment.id, deployment_id: older_deployment.id)
+
+ expect { subject }.not_to change { Ci::Build.failed.count }
+ end
+ end
+
+ context 'when ActiveRecord::StaleObjectError is raised' do
+ before do
+ allow_any_instance_of(Ci::Build)
+ .to receive(:drop).and_raise(ActiveRecord::StaleObjectError)
+ end
+
+ it 'resets the object via Gitlab::OptimisticLocking' do
+ allow_any_instance_of(Ci::Build).to receive(:reset).at_least(:once)
+
+ subject
+ end
+ end
+
context 'and there is no deployable for that older deployment' do
let(:older_deployment) { create(:deployment, :running, environment: environment, deployable: nil) }
diff --git a/spec/services/design_management/delete_designs_service_spec.rb b/spec/services/design_management/delete_designs_service_spec.rb
new file mode 100644
index 00000000000..2c0c1570cb4
--- /dev/null
+++ b/spec/services/design_management/delete_designs_service_spec.rb
@@ -0,0 +1,195 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe DesignManagement::DeleteDesignsService do
+ include DesignManagementTestHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:user) { create(:user) }
+ let(:designs) { create_designs }
+
+ subject(:service) { described_class.new(project, user, issue: issue, designs: designs) }
+
+ # Defined as a method so that the reponse is not cached. We also construct
+ # a new service executor each time to avoid the intermediate cached values
+ # it constructs during its execution.
+ def run_service(delenda = nil)
+ service = described_class.new(project, user, issue: issue, designs: delenda || designs)
+ service.execute
+ end
+
+ let(:response) { run_service }
+
+ shared_examples 'a service error' do
+ it 'returns an error', :aggregate_failures do
+ expect(response).to include(status: :error)
+ end
+ end
+
+ shared_examples 'a top-level error' do
+ let(:expected_error) { StandardError }
+ it 'raises an en expected error', :aggregate_failures do
+ expect { run_service }.to raise_error(expected_error)
+ end
+ end
+
+ shared_examples 'a success' do
+ it 'returns successfully', :aggregate_failures do
+ expect(response).to include(status: :success)
+ end
+
+ it 'saves the user as the author' do
+ version = response[:version]
+
+ expect(version.author).to eq(user)
+ end
+ end
+
+ before do
+ enable_design_management(enabled)
+ project.add_developer(user)
+ end
+
+ describe "#execute" do
+ context "when the feature is not available" do
+ let(:enabled) { false }
+
+ it_behaves_like "a service error"
+ end
+
+ context "when the feature is available" do
+ let(:enabled) { true }
+
+ it 'is able to delete designs' do
+ expect(service.send(:can_delete_designs?)).to be true
+ end
+
+ context 'no designs were passed' do
+ let(:designs) { [] }
+
+ it_behaves_like "a top-level error"
+
+ it 'does not log any events' do
+ counter = ::Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service rescue nil }.not_to change { counter.totals }
+ end
+ end
+
+ context 'one design is passed' do
+ before do
+ create_designs(2)
+ end
+
+ let!(:designs) { create_designs(1) }
+
+ it 'removes that design' do
+ expect { run_service }.to change { issue.designs.current.count }.from(3).to(2)
+ end
+
+ it 'logs a deletion event' do
+ counter = ::Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service }.to change { counter.read(:delete) }.by(1)
+ end
+
+ it 'informs the new-version-worker' do
+ expect(::DesignManagement::NewVersionWorker).to receive(:perform_async).with(Integer)
+
+ run_service
+ end
+
+ it 'creates a new version' do
+ expect { run_service }.to change { DesignManagement::Version.where(issue: issue).count }.by(1)
+ end
+
+ it 'returns the new version' do
+ version = response[:version]
+
+ expect(version).to eq(DesignManagement::Version.for_issue(issue).ordered.first)
+ end
+
+ it_behaves_like "a success"
+
+ it 'removes the design from the current design list' do
+ run_service
+
+ expect(issue.designs.current).not_to include(designs.first)
+ end
+
+ it 'marks the design as deleted' do
+ expect { run_service }
+ .to change { designs.first.deleted? }.from(false).to(true)
+ end
+ end
+
+ context 'more than one design is passed' do
+ before do
+ create_designs(1)
+ end
+
+ let!(:designs) { create_designs(2) }
+
+ it 'removes those designs' do
+ expect { run_service }
+ .to change { issue.designs.current.count }.from(3).to(1)
+ end
+
+ it 'logs the correct number of deletion events' do
+ counter = ::Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service }.to change { counter.read(:delete) }.by(2)
+ end
+
+ it_behaves_like "a success"
+
+ context 'after executing the service' do
+ let(:deleted_designs) { designs.map(&:reset) }
+
+ let!(:version) { run_service[:version] }
+
+ it 'removes the removed designs from the current design list' do
+ expect(issue.designs.current).not_to include(*deleted_designs)
+ end
+
+ it 'does not make the designs impossible to find' do
+ expect(issue.designs).to include(*deleted_designs)
+ end
+
+ it 'associates the new version with all the designs' do
+ current_versions = deleted_designs.map { |d| d.most_recent_action.version }
+ expect(current_versions).to all(eq version)
+ end
+
+ it 'marks all deleted designs as deleted' do
+ expect(deleted_designs).to all(be_deleted)
+ end
+
+ it 'marks all deleted designs with the same deletion version' do
+ expect(deleted_designs.map { |d| d.most_recent_action.version_id }.uniq)
+ .to have_attributes(size: 1)
+ end
+ end
+ end
+
+ describe 'scalability' do
+ before do
+ run_service(create_designs(1)) # ensure project, issue, etc are created
+ end
+
+ it 'makes the same number of DB requests for one design as for several' do
+ one = create_designs(1)
+ many = create_designs(5)
+
+ baseline = ActiveRecord::QueryRecorder.new { run_service(one) }
+
+ expect { run_service(many) }.not_to exceed_query_limit(baseline)
+ end
+ end
+ end
+ end
+
+ private
+
+ def create_designs(how_many = 2)
+ create_list(:design, how_many, :with_lfs_file, issue: issue)
+ end
+end
diff --git a/spec/services/design_management/design_user_notes_count_service_spec.rb b/spec/services/design_management/design_user_notes_count_service_spec.rb
new file mode 100644
index 00000000000..62211a4dd0f
--- /dev/null
+++ b/spec/services/design_management/design_user_notes_count_service_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::DesignUserNotesCountService, :use_clean_rails_memory_store_caching do
+ let_it_be(:design) { create(:design, :with_file) }
+
+ subject { described_class.new(design) }
+
+ it_behaves_like 'a counter caching service'
+
+ describe '#count' do
+ it 'returns the count of notes' do
+ create_list(:diff_note_on_design, 3, noteable: design)
+
+ expect(subject.count).to eq(3)
+ end
+ end
+
+ describe '#cache_key' do
+ it 'contains the `VERSION` and `design.id`' do
+ expect(subject.cache_key).to eq(['designs', 'notes_count', DesignManagement::DesignUserNotesCountService::VERSION, design.id])
+ end
+ end
+
+ describe 'cache invalidation' do
+ it 'changes when a new note is created' do
+ new_note_attrs = attributes_for(:diff_note_on_design, noteable: design)
+
+ expect do
+ Notes::CreateService.new(design.project, create(:user), new_note_attrs).execute
+ end.to change { subject.count }.by(1)
+ end
+
+ it 'changes when a note is destroyed' do
+ note = create(:diff_note_on_design, noteable: design)
+
+ expect do
+ Notes::DestroyService.new(note.project, note.author).execute(note)
+ end.to change { subject.count }.by(-1)
+ end
+ end
+end
diff --git a/spec/services/design_management/generate_image_versions_service_spec.rb b/spec/services/design_management/generate_image_versions_service_spec.rb
new file mode 100644
index 00000000000..cd021c8d7d3
--- /dev/null
+++ b/spec/services/design_management/generate_image_versions_service_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::GenerateImageVersionsService do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:version) { create(:design, :with_lfs_file, issue: issue).versions.first }
+ let_it_be(:action) { version.actions.first }
+
+ describe '#execute' do
+ it 'generates the image' do
+ expect { described_class.new(version).execute }
+ .to change { action.reload.image_v432x230.file }
+ .from(nil).to(CarrierWave::SanitizedFile)
+ end
+
+ it 'skips generating image versions if the mime type is not whitelisted' do
+ stub_const('DesignManagement::DesignV432x230Uploader::MIME_TYPE_WHITELIST', [])
+
+ described_class.new(version).execute
+
+ expect(action.reload.image_v432x230.file).to eq(nil)
+ end
+
+ it 'skips generating image versions if the design file size is too large' do
+ stub_const("#{described_class.name}::MAX_DESIGN_SIZE", 1.byte)
+
+ described_class.new(version).execute
+
+ expect(action.reload.image_v432x230.file).to eq(nil)
+ end
+
+ it 'returns the status' do
+ result = described_class.new(version).execute
+
+ expect(result[:status]).to eq(:success)
+ end
+
+ it 'returns the version' do
+ result = described_class.new(version).execute
+
+ expect(result[:version]).to eq(version)
+ end
+
+ it 'logs if the raw image cannot be found' do
+ version.designs.first.update(filename: 'foo.png')
+
+ expect(Gitlab::AppLogger).to receive(:error).with("No design file found for Action: #{action.id}")
+
+ described_class.new(version).execute
+ end
+
+ context 'when an error is encountered when generating the image versions' do
+ before do
+ expect_next_instance_of(DesignManagement::DesignV432x230Uploader) do |uploader|
+ expect(uploader).to receive(:cache!).and_raise(CarrierWave::DownloadError, 'foo')
+ end
+ end
+
+ it 'logs the error' do
+ expect(Gitlab::AppLogger).to receive(:error).with('foo')
+
+ described_class.new(version).execute
+ end
+
+ it 'tracks the error' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
+ instance_of(CarrierWave::DownloadError),
+ project_id: project.id, version_id: version.id, design_id: version.designs.first.id
+ )
+
+ described_class.new(version).execute
+ end
+ end
+ end
+end
diff --git a/spec/services/design_management/save_designs_service_spec.rb b/spec/services/design_management/save_designs_service_spec.rb
new file mode 100644
index 00000000000..013d5473860
--- /dev/null
+++ b/spec/services/design_management/save_designs_service_spec.rb
@@ -0,0 +1,356 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe DesignManagement::SaveDesignsService do
+ include DesignManagementTestHelpers
+ include ConcurrentHelpers
+
+ let_it_be(:developer) { create(:user) }
+ let(:project) { issue.project }
+ let(:issue) { create(:issue) }
+ let(:user) { developer }
+ let(:files) { [rails_sample] }
+ let(:design_repository) { ::Gitlab::GlRepository::DESIGN.repository_resolver.call(project) }
+ let(:rails_sample_name) { 'rails_sample.jpg' }
+ let(:rails_sample) { sample_image(rails_sample_name) }
+ let(:dk_png) { sample_image('dk.png') }
+
+ def sample_image(filename)
+ fixture_file_upload("spec/fixtures/#{filename}")
+ end
+
+ before do
+ project.add_developer(developer)
+ end
+
+ def run_service(files_to_upload = nil)
+ design_files = files_to_upload || files
+ design_files.each(&:rewind)
+
+ service = described_class.new(project, user,
+ issue: issue,
+ files: design_files)
+ service.execute
+ end
+
+ # Randomly alter the content of files.
+ # This allows the files to be updated by the service, as unmodified
+ # files are rejected.
+ def touch_files(files_to_touch = nil)
+ design_files = files_to_touch || files
+
+ design_files.each do |f|
+ f.tempfile.write(SecureRandom.random_bytes)
+ end
+ end
+
+ let(:response) { run_service }
+
+ shared_examples 'a service error' do
+ it 'returns an error', :aggregate_failures do
+ expect(response).to match(a_hash_including(status: :error))
+ end
+ end
+
+ shared_examples 'an execution error' do
+ it 'returns an error', :aggregate_failures do
+ expect { service.execute }.to raise_error(some_error)
+ end
+ end
+
+ describe '#execute' do
+ context 'when the feature is not available' do
+ before do
+ enable_design_management(false)
+ end
+
+ it_behaves_like 'a service error'
+ end
+
+ context 'when the feature is available' do
+ before do
+ enable_design_management(true)
+ end
+
+ describe 'repository existence' do
+ def repository_exists
+ # Expire the memoized value as the service creates it's own instance
+ design_repository.expire_exists_cache
+ design_repository.exists?
+ end
+
+ it 'creates a design repository when it did not exist' do
+ expect { run_service }.to change { repository_exists }.from(false).to(true)
+ end
+ end
+
+ it 'updates the creation count' do
+ counter = Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service }.to change { counter.read(:create) }.by(1)
+ end
+
+ it 'creates a commit in the repository' do
+ run_service
+
+ expect(design_repository.commit).to have_attributes(
+ author: user,
+ message: include(rails_sample_name)
+ )
+ end
+
+ it 'can run the same command in parallel' do
+ blocks = Array.new(10).map do
+ unique_files = %w(rails_sample.jpg dk.png)
+ .map { |name| RenameableUpload.unique_file(name) }
+
+ -> { run_service(unique_files) }
+ end
+
+ expect { run_parallel(blocks) }.to change(DesignManagement::Version, :count).by(10)
+ end
+
+ it 'causes diff_refs not to be nil' do
+ expect(response).to include(
+ designs: all(have_attributes(diff_refs: be_present))
+ )
+ end
+
+ it 'creates a design & a version for the filename if it did not exist' do
+ expect(issue.designs.size).to eq(0)
+
+ updated_designs = response[:designs]
+
+ expect(updated_designs.size).to eq(1)
+ expect(updated_designs.first.versions.size).to eq(1)
+ end
+
+ it 'saves the user as the author' do
+ updated_designs = response[:designs]
+
+ expect(updated_designs.first.versions.first.author).to eq(user)
+ end
+
+ describe 'saving the file to LFS' do
+ before do
+ expect_next_instance_of(Lfs::FileTransformer) do |transformer|
+ expect(transformer).to receive(:lfs_file?).and_return(true)
+ end
+ end
+
+ it 'saves the design to LFS' do
+ expect { run_service }.to change { LfsObject.count }.by(1)
+ end
+
+ it 'saves the repository_type of the LfsObjectsProject as design' do
+ expect do
+ run_service
+ end.to change { project.lfs_objects_projects.count }.from(0).to(1)
+
+ expect(project.lfs_objects_projects.first.repository_type).to eq('design')
+ end
+ end
+
+ context 'when a design is being updated' do
+ before do
+ run_service
+ touch_files
+ end
+
+ it 'creates a new version for the existing design and updates the file' do
+ expect(issue.designs.size).to eq(1)
+ expect(DesignManagement::Version.for_designs(issue.designs).size).to eq(1)
+
+ updated_designs = response[:designs]
+
+ expect(updated_designs.size).to eq(1)
+ expect(updated_designs.first.versions.size).to eq(2)
+ end
+
+ it 'increments the update counter' do
+ counter = Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service }.to change { counter.read(:update) }.by 1
+ end
+
+ context 'when uploading a new design' do
+ it 'does not link the new version to the existing design' do
+ existing_design = issue.designs.first
+
+ updated_designs = run_service([dk_png])[:designs]
+
+ expect(existing_design.versions.reload.size).to eq(1)
+ expect(updated_designs.size).to eq(1)
+ expect(updated_designs.first.versions.size).to eq(1)
+ end
+ end
+ end
+
+ context 'when a design has not changed since its previous version' do
+ before do
+ run_service
+ end
+
+ it 'does not create a new version' do
+ expect { run_service }.not_to change { issue.design_versions.count }
+ end
+
+ it 'returns the design in `skipped_designs` instead of `designs`' do
+ response = run_service
+
+ expect(response[:designs]).to be_empty
+ expect(response[:skipped_designs].size).to eq(1)
+ end
+ end
+
+ context 'when doing a mixture of updates and creations' do
+ let(:files) { [rails_sample, dk_png] }
+
+ before do
+ # Create just the first one, which we will later update.
+ run_service([files.first])
+ touch_files([files.first])
+ end
+
+ it 'counts one creation and one update' do
+ counter = Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service }
+ .to change { counter.read(:create) }.by(1)
+ .and change { counter.read(:update) }.by(1)
+ end
+
+ it 'creates a single commit' do
+ commit_count = -> do
+ design_repository.expire_all_method_caches
+ design_repository.commit_count
+ end
+
+ expect { run_service }.to change { commit_count.call }.by(1)
+ end
+
+ it 'enqueues just one new version worker' do
+ expect(::DesignManagement::NewVersionWorker)
+ .to receive(:perform_async).once.with(Integer)
+
+ run_service
+ end
+ end
+
+ context 'when uploading multiple files' do
+ let(:files) { [rails_sample, dk_png] }
+
+ it 'returns information about both designs in the response' do
+ expect(response).to include(designs: have_attributes(size: 2), status: :success)
+ end
+
+ it 'creates 2 designs with a single version' do
+ expect { run_service }.to change { issue.designs.count }.from(0).to(2)
+
+ expect(DesignManagement::Version.for_designs(issue.designs).size).to eq(1)
+ end
+
+ it 'increments the creation count by 2' do
+ counter = Gitlab::UsageDataCounters::DesignsCounter
+ expect { run_service }.to change { counter.read(:create) }.by 2
+ end
+
+ it 'enqueues a new version worker' do
+ expect(::DesignManagement::NewVersionWorker)
+ .to receive(:perform_async).once.with(Integer)
+
+ run_service
+ end
+
+ it 'creates a single commit' do
+ commit_count = -> do
+ design_repository.expire_all_method_caches
+ design_repository.commit_count
+ end
+
+ expect { run_service }.to change { commit_count.call }.by(1)
+ end
+
+ it 'only does 5 gitaly calls', :request_store, :sidekiq_might_not_need_inline do
+ allow(::DesignManagement::NewVersionWorker).to receive(:perform_async).with(Integer)
+ service = described_class.new(project, user, issue: issue, files: files)
+ # Some unrelated calls that are usually cached or happen only once
+ service.__send__(:repository).create_if_not_exists
+ service.__send__(:repository).has_visible_content?
+
+ request_count = -> { Gitlab::GitalyClient.get_request_count }
+
+ # An exists?, a check for existing blobs, default branch, an after_commit
+ # callback on LfsObjectsProject
+ expect { service.execute }.to change(&request_count).by(4)
+ end
+
+ context 'when uploading too many files' do
+ let(:files) { Array.new(DesignManagement::SaveDesignsService::MAX_FILES + 1) { dk_png } }
+
+ it 'returns the correct error' do
+ expect(response[:message]).to match(/only \d+ files are allowed simultaneously/i)
+ end
+ end
+ end
+
+ context 'when the user is not allowed to upload designs' do
+ let(:user) { create(:user) }
+
+ it_behaves_like 'a service error'
+ end
+
+ describe 'failure modes' do
+ let(:service) { described_class.new(project, user, issue: issue, files: files) }
+ let(:response) { service.execute }
+
+ before do
+ expect(service).to receive(:run_actions).and_raise(some_error)
+ end
+
+ context 'when creating the commit fails' do
+ let(:some_error) { Gitlab::Git::BaseError }
+
+ it_behaves_like 'an execution error'
+ end
+
+ context 'when creating the versions fails' do
+ let(:some_error) { ActiveRecord::RecordInvalid }
+
+ it_behaves_like 'a service error'
+ end
+ end
+
+ context "when a design already existed in the repo but we didn't know about it in the database" do
+ let(:filename) { rails_sample_name }
+
+ before do
+ path = File.join(build(:design, issue: issue, filename: filename).full_path)
+ design_repository.create_if_not_exists
+ design_repository.create_file(user, path, 'something fake',
+ branch_name: 'master',
+ message: 'Somehow created without being tracked in db')
+ end
+
+ it 'creates the design and a new version for it' do
+ first_updated_design = response[:designs].first
+
+ expect(first_updated_design.filename).to eq(filename)
+ expect(first_updated_design.versions.size).to eq(1)
+ end
+ end
+
+ describe 'scalability', skip: 'See: https://gitlab.com/gitlab-org/gitlab/-/issues/213169' do
+ before do
+ run_service([sample_image('banana_sample.gif')]) # ensure project, issue, etc are created
+ end
+
+ it 'runs the same queries for all requests, regardless of number of files' do
+ one = [dk_png]
+ two = [rails_sample, dk_png]
+
+ baseline = ActiveRecord::QueryRecorder.new { run_service(one) }
+
+ expect { run_service(two) }.not_to exceed_query_limit(baseline)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/emails/confirm_service_spec.rb b/spec/services/emails/confirm_service_spec.rb
index 6a274ca9dfe..973d2731b2f 100644
--- a/spec/services/emails/confirm_service_spec.rb
+++ b/spec/services/emails/confirm_service_spec.rb
@@ -8,10 +8,10 @@ describe Emails::ConfirmService do
subject(:service) { described_class.new(user) }
describe '#execute' do
- it 'sends a confirmation email again' do
+ it 'enqueues a background job to send confirmation email again' do
email = user.emails.create(email: 'new@email.com')
- mail = service.execute(email)
- expect(mail.subject).to eq('Confirmation instructions')
+
+ expect { service.execute(email) }.to have_enqueued_job.on_queue('mailers')
end
end
end
diff --git a/spec/services/event_create_service_spec.rb b/spec/services/event_create_service_spec.rb
index 0a8a4d5bf58..987b4ad68f7 100644
--- a/spec/services/event_create_service_spec.rb
+++ b/spec/services/event_create_service_spec.rb
@@ -162,16 +162,25 @@ describe EventCreateService do
context "The action is #{action}" do
let(:event) { service.wiki_event(meta, user, action) }
- it 'creates the event' do
+ it 'creates the event', :aggregate_failures do
expect(event).to have_attributes(
wiki_page?: true,
valid?: true,
persisted?: true,
action: action,
- wiki_page: wiki_page
+ wiki_page: wiki_page,
+ author: user
)
end
+ it 'is idempotent', :aggregate_failures do
+ expect { event }.to change(Event, :count).by(1)
+ duplicate = nil
+ expect { duplicate = service.wiki_event(meta, user, action) }.not_to change(Event, :count)
+
+ expect(duplicate).to eq(event)
+ end
+
context 'the feature is disabled' do
before do
stub_feature_flags(wiki_events: false)
diff --git a/spec/services/git/branch_push_service_spec.rb b/spec/services/git/branch_push_service_spec.rb
index acd14005c69..6ecc1a62ff3 100644
--- a/spec/services/git/branch_push_service_spec.rb
+++ b/spec/services/git/branch_push_service_spec.rb
@@ -291,7 +291,7 @@ describe Git::BranchPushService, services: true do
execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
end
- it "defaults to the pushing user if the commit's author is not known", :sidekiq_might_not_need_inline do
+ it "defaults to the pushing user if the commit's author is not known", :sidekiq_inline, :use_clean_rails_redis_caching do
allow(commit).to receive_messages(
author_name: 'unknown name',
author_email: 'unknown@email.com'
@@ -315,7 +315,7 @@ describe Git::BranchPushService, services: true do
let(:issue) { create :issue, project: project }
let(:commit_author) { create :user }
let(:commit) { project.commit }
- let(:commit_time) { Time.now }
+ let(:commit_time) { Time.current }
before do
project.add_developer(commit_author)
@@ -336,7 +336,7 @@ describe Git::BranchPushService, services: true do
end
context "while saving the 'first_mentioned_in_commit_at' metric for an issue" do
- it 'sets the metric for referenced issues', :sidekiq_might_not_need_inline do
+ it 'sets the metric for referenced issues', :sidekiq_inline, :use_clean_rails_redis_caching do
execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
expect(issue.reload.metrics.first_mentioned_in_commit_at).to be_like_time(commit_time)
@@ -397,7 +397,7 @@ describe Git::BranchPushService, services: true do
allow(project).to receive(:default_branch).and_return('not-master')
end
- it "creates cross-reference notes", :sidekiq_might_not_need_inline do
+ it "creates cross-reference notes", :sidekiq_inline, :use_clean_rails_redis_caching do
expect(SystemNoteService).to receive(:cross_reference).with(issue, closing_commit, commit_author)
execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
end
@@ -438,7 +438,7 @@ describe Git::BranchPushService, services: true do
context "mentioning an issue" do
let(:message) { "this is some work.\n\nrelated to JIRA-1" }
- it "initiates one api call to jira server to mention the issue", :sidekiq_might_not_need_inline do
+ it "initiates one api call to jira server to mention the issue", :sidekiq_inline, :use_clean_rails_redis_caching do
execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
expect(WebMock).to have_requested(:post, jira_api_comment_url('JIRA-1')).with(
diff --git a/spec/services/git/wiki_push_service/change_spec.rb b/spec/services/git/wiki_push_service/change_spec.rb
new file mode 100644
index 00000000000..547874270ab
--- /dev/null
+++ b/spec/services/git/wiki_push_service/change_spec.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Git::WikiPushService::Change do
+ subject { described_class.new(project_wiki, change, raw_change) }
+
+ let(:project_wiki) { double('ProjectWiki') }
+ let(:raw_change) { double('RawChange', new_path: new_path, old_path: old_path, operation: operation) }
+ let(:change) { { oldrev: generate(:sha), newrev: generate(:sha) } }
+
+ let(:new_path) do
+ case operation
+ when :deleted
+ nil
+ else
+ generate(:wiki_filename)
+ end
+ end
+
+ let(:old_path) do
+ case operation
+ when :added
+ nil
+ when :deleted, :renamed
+ generate(:wiki_filename)
+ else
+ new_path
+ end
+ end
+
+ describe '#page' do
+ context 'the page does not exist' do
+ before do
+ expect(project_wiki).to receive(:find_page).with(String, String).and_return(nil)
+ end
+
+ %i[added deleted renamed modified].each do |op|
+ context "the operation is #{op}" do
+ let(:operation) { op }
+
+ it { is_expected.to have_attributes(page: be_nil) }
+ end
+ end
+ end
+
+ context 'the page can be found' do
+ let(:wiki_page) { double('WikiPage') }
+
+ before do
+ expect(project_wiki).to receive(:find_page).with(slug, revision).and_return(wiki_page)
+ end
+
+ context 'the page has been deleted' do
+ let(:operation) { :deleted }
+ let(:slug) { old_path.chomp('.md') }
+ let(:revision) { change[:oldrev] }
+
+ it { is_expected.to have_attributes(page: wiki_page) }
+ end
+
+ %i[added renamed modified].each do |op|
+ let(:operation) { op }
+ let(:slug) { new_path.chomp('.md') }
+ let(:revision) { change[:newrev] }
+
+ it { is_expected.to have_attributes(page: wiki_page) }
+ end
+ end
+ end
+
+ describe '#last_known_slug' do
+ context 'the page has been created' do
+ let(:operation) { :added }
+
+ it { is_expected.to have_attributes(last_known_slug: new_path.chomp('.md')) }
+ end
+
+ %i[renamed modified deleted].each do |op|
+ context "the operation is #{op}" do
+ let(:operation) { op }
+
+ it { is_expected.to have_attributes(last_known_slug: old_path.chomp('.md')) }
+ end
+ end
+ end
+
+ describe '#event_action' do
+ context 'the page is deleted' do
+ let(:operation) { :deleted }
+
+ it { is_expected.to have_attributes(event_action: Event::DESTROYED) }
+ end
+
+ context 'the page is added' do
+ let(:operation) { :added }
+
+ it { is_expected.to have_attributes(event_action: Event::CREATED) }
+ end
+
+ %i[renamed modified].each do |op|
+ context "the page is #{op}" do
+ let(:operation) { op }
+
+ it { is_expected.to have_attributes(event_action: Event::UPDATED) }
+ end
+ end
+ end
+end
diff --git a/spec/services/git/wiki_push_service_spec.rb b/spec/services/git/wiki_push_service_spec.rb
new file mode 100644
index 00000000000..cdb1dc5a435
--- /dev/null
+++ b/spec/services/git/wiki_push_service_spec.rb
@@ -0,0 +1,338 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+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 }
+
+ describe '#execute' 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 } }
+ end
+
+ it 'creates only MAX_CHANGES events' do
+ expect { run_service }.to change(Event, :count).by(described_class::MAX_CHANGES)
+ end
+ end
+
+ context 'default_branch collides with a tag' do
+ it 'creates only one event' do
+ base_sha = current_sha
+ write_new_page
+
+ service = create_service(base_sha, ['refs/heads/master', 'refs/tags/master'])
+
+ expect { service.execute }.to change(Event, :count).by(1)
+ end
+ end
+
+ describe 'successfully creating events' 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)
+
+ process_changes do
+ write_new_page
+ update_page(wiki_page_a.title)
+ delete_page(wiki_page_b.page.path)
+ end
+ end
+
+ it 'creates one event for every wiki action' do
+ expect { run_service }.to change(Event, :count).by(count)
+ end
+
+ it 'handles all known actions' do
+ run_service
+
+ expect(Event.last(count).pluck(:action)).to match_array(Event::WIKI_ACTIONS)
+ end
+ end
+
+ context 'two pages have been created' do
+ def run_service
+ process_changes do
+ write_new_page
+ write_new_page
+ end
+ end
+
+ it 'creates two events' do
+ expect { run_service }.to change(Event, :count).by(2)
+ end
+
+ it 'creates two metadata records' do
+ expect { run_service }.to change(WikiPage::Meta, :count).by(2)
+ end
+
+ it 'creates appropriate events' do
+ run_service
+
+ expect(Event.last(2)).to all(have_attributes(wiki_page?: true, action: Event::CREATED))
+ end
+ end
+
+ context 'a non-page file as been added' do
+ it 'does not create events, or WikiPage metadata' do
+ expect do
+ process_changes { write_non_page }
+ end.not_to change { [Event.count, WikiPage::Meta.count] }
+ end
+ end
+
+ context 'one page, and one non-page have been created' do
+ def run_service
+ process_changes do
+ write_new_page
+ write_non_page
+ end
+ end
+
+ it 'creates a wiki page creation event' do
+ expect { run_service }.to change(Event, :count).by(1)
+
+ expect(Event.last).to have_attributes(wiki_page?: true, action: Event::CREATED)
+ end
+
+ it 'creates one metadata record' do
+ expect { run_service }.to change(WikiPage::Meta, :count).by(1)
+ end
+ end
+
+ context 'one page has been added, and then updated' do
+ def run_service
+ process_changes do
+ title = write_new_page
+ update_page(title)
+ end
+ end
+
+ it 'creates just a single event' do
+ expect { run_service }.to change(Event, :count).by(1)
+ end
+
+ it 'creates just one metadata record' do
+ expect { run_service }.to change(WikiPage::Meta, :count).by(1)
+ end
+
+ it 'creates a new wiki page creation event' do
+ run_service
+
+ expect(Event.last).to have_attributes(
+ wiki_page?: true,
+ action: Event::CREATED
+ )
+ end
+ end
+
+ context 'when a page we already know about has been updated' do
+ let(:wiki_page) { create(:wiki_page, project: project) }
+
+ before do
+ create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page)
+ end
+
+ def run_service
+ process_changes { update_page(wiki_page.title) }
+ end
+
+ it 'does not create a new meta-data record' do
+ expect { run_service }.not_to change(WikiPage::Meta, :count)
+ end
+
+ it 'creates a new event' do
+ expect { run_service }.to change(Event, :count).by(1)
+ end
+
+ it 'adds an update event' do
+ run_service
+
+ expect(Event.last).to have_attributes(
+ wiki_page?: true,
+ action: Event::UPDATED
+ )
+ end
+ end
+
+ context 'when a page we do not know about has been updated' do
+ def run_service
+ wiki_page = create(:wiki_page, project: project)
+ process_changes { update_page(wiki_page.title) }
+ end
+
+ it 'creates a new meta-data record' do
+ expect { run_service }.to change(WikiPage::Meta, :count).by(1)
+ end
+
+ it 'creates a new event' do
+ expect { run_service }.to change(Event, :count).by(1)
+ end
+
+ it 'adds an update event' do
+ run_service
+
+ expect(Event.last).to have_attributes(
+ wiki_page?: true,
+ action: Event::UPDATED
+ )
+ end
+ end
+
+ context 'when a page we do not know about has been deleted' do
+ def run_service
+ wiki_page = create(:wiki_page, project: project)
+ process_changes { delete_page(wiki_page.page.path) }
+ end
+
+ it 'create a new meta-data record' do
+ expect { run_service }.to change(WikiPage::Meta, :count).by(1)
+ end
+
+ it 'creates a new event' do
+ expect { run_service }.to change(Event, :count).by(1)
+ end
+
+ it 'adds an update event' do
+ run_service
+
+ expect(Event.last).to have_attributes(
+ wiki_page?: true,
+ action: Event::DESTROYED
+ )
+ end
+ end
+
+ it 'calls log_error for every event we cannot create' do
+ base_sha = current_sha
+ count = 3
+ count.times { write_new_page }
+ message = 'something went very very wrong'
+ allow_next_instance_of(WikiPages::EventCreateService, current_user) do |service|
+ allow(service).to receive(:execute)
+ .with(String, WikiPage, Integer)
+ .and_return(ServiceResponse.error(message: message))
+ end
+
+ service = create_service(base_sha)
+
+ expect(service).to receive(:log_error).exactly(count).times.with(message)
+
+ service.execute
+ end
+
+ describe 'feature flags' do
+ shared_examples 'a no-op push' do
+ it 'does not create any events' do
+ expect { process_changes { write_new_page } }.not_to change(Event, :count)
+ end
+
+ it 'does not even look for events to process' do
+ base_sha = current_sha
+ write_new_page
+
+ service = create_service(base_sha)
+
+ expect(service).not_to receive(:changed_files)
+
+ service.execute
+ end
+ end
+
+ context 'the wiki_events feature is disabled' do
+ before do
+ stub_feature_flags(wiki_events: false)
+ end
+
+ it_behaves_like 'a no-op push'
+ end
+
+ context 'the wiki_events_on_git_push feature is disabled' do
+ before do
+ stub_feature_flags(wiki_events_on_git_push: false)
+ end
+
+ it_behaves_like 'a no-op push'
+
+ context 'but is enabled for a given project' do
+ before do
+ stub_feature_flags(wiki_events_on_git_push: project)
+ end
+
+ it 'creates events' do
+ expect { process_changes { write_new_page } }.to change(Event, :count).by(1)
+ end
+ end
+ end
+ end
+ end
+
+ # In order to construct the correct GitPostReceive object that represents the
+ # changes we are applying, we need to describe the changes between old-ref and
+ # new-ref. Old ref (the base sha) we have to capture before we perform any
+ # changes. Once the changes have been applied, we can execute the service to
+ # process them.
+ def process_changes(&block)
+ base_sha = current_sha
+ yield
+ create_service(base_sha).execute
+ end
+
+ def create_service(base, refs = ['refs/heads/master'])
+ changes = post_received(base, refs).changes
+ described_class.new(project, 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, {})
+ 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
+ end
+
+ # It is important not to re-use the WikiPage services here, since they create
+ # events - these helper methods below are intended to simulate actions on the repo
+ # that have not gone through our services.
+
+ def write_new_page
+ generate(:wiki_page_title).tap { |t| git_wiki.write_page(t, 'markdown', 'Hello', commit_details) }
+ end
+
+ # We write something to the wiki-repo that is not a page - as, for example, an
+ # attachment. This will appear as a raw-diff change, but wiki.find_page will
+ # return nil.
+ def write_non_page
+ params = {
+ file_name: 'attachment.log',
+ file_content: 'some stuff',
+ branch_name: 'master'
+ }
+ ::Wikis::CreateAttachmentService.new(container: project, current_user: project.owner, params: params).execute
+ end
+
+ def update_page(title)
+ page = git_wiki.page(title: title)
+ git_wiki.update_page(page.path, title, 'markdown', 'Hey', commit_details)
+ end
+
+ def delete_page(path)
+ git_wiki.delete_page(path, commit_details)
+ end
+
+ def commit_details
+ create(:git_wiki_commit_details, author: current_user)
+ end
+end
diff --git a/spec/services/grafana/proxy_service_spec.rb b/spec/services/grafana/proxy_service_spec.rb
index 694d531c9fc..8cb7210524a 100644
--- a/spec/services/grafana/proxy_service_spec.rb
+++ b/spec/services/grafana/proxy_service_spec.rb
@@ -66,7 +66,7 @@ describe Grafana::ProxyService do
context 'with caching', :use_clean_rails_memory_store_caching do
context 'when value not present in cache' do
it 'returns nil' do
- expect(ReactiveCachingWorker)
+ expect(ExternalServiceReactiveCachingWorker)
.to receive(:perform_async)
.with(service.class, service.id, *cache_params)
diff --git a/spec/services/groups/create_service_spec.rb b/spec/services/groups/create_service_spec.rb
index 5cde9a3ed45..c0e876cce33 100644
--- a/spec/services/groups/create_service_spec.rb
+++ b/spec/services/groups/create_service_spec.rb
@@ -24,6 +24,27 @@ describe Groups::CreateService, '#execute' do
end
end
+ context 'creating a group with `default_branch_protection` attribute' do
+ let(:params) { group_params.merge(default_branch_protection: Gitlab::Access::PROTECTION_NONE) }
+ let(:service) { described_class.new(user, params) }
+ let(:created_group) { service.execute }
+
+ context 'for users who have the ability to create a group with `default_branch_protection`' do
+ it 'creates group with the specified branch protection level' do
+ expect(created_group.default_branch_protection).to eq(Gitlab::Access::PROTECTION_NONE)
+ end
+ end
+
+ context 'for users who do not have the ability to create a group with `default_branch_protection`' do
+ it 'does not create the group with the specified branch protection level' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user, :create_group_with_default_branch_protection) { false }
+
+ expect(created_group.default_branch_protection).not_to eq(Gitlab::Access::PROTECTION_NONE)
+ end
+ end
+ end
+
describe 'creating a top level group' do
let(:service) { described_class.new(user, group_params) }
diff --git a/spec/services/groups/import_export/export_service_spec.rb b/spec/services/groups/import_export/export_service_spec.rb
index 56c7121cc34..7bad68b4e00 100644
--- a/spec/services/groups/import_export/export_service_spec.rb
+++ b/spec/services/groups/import_export/export_service_spec.rb
@@ -11,7 +11,7 @@ describe Groups::ImportExport::ExportService do
let(:export_service) { described_class.new(group: group, user: user) }
it 'enqueues an export job' do
- expect(GroupExportWorker).to receive(:perform_async).with(user.id, group.id, {})
+ allow(GroupExportWorker).to receive(:perform_async).with(user.id, group.id, {})
export_service.async_execute
end
@@ -49,12 +49,36 @@ describe Groups::ImportExport::ExportService do
FileUtils.rm_rf(archive_path)
end
- it 'saves the models' do
+ it 'saves the version' do
+ expect(Gitlab::ImportExport::VersionSaver).to receive(:new).and_call_original
+
+ service.execute
+ end
+
+ it 'saves the models using ndjson tree saver' do
+ stub_feature_flags(group_export_ndjson: true)
+
+ expect(Gitlab::ImportExport::Group::TreeSaver).to receive(:new).and_call_original
+
+ service.execute
+ end
+
+ it 'saves the models using legacy tree saver' do
+ stub_feature_flags(group_export_ndjson: false)
+
expect(Gitlab::ImportExport::Group::LegacyTreeSaver).to receive(:new).and_call_original
service.execute
end
+ it 'notifies the user' do
+ expect_next_instance_of(NotificationService) do |instance|
+ expect(instance).to receive(:group_was_exported)
+ end
+
+ service.execute
+ end
+
context 'when saver succeeds' do
it 'saves the group in the file system' do
service.execute
@@ -98,16 +122,26 @@ describe Groups::ImportExport::ExportService do
context 'when export fails' do
context 'when file saver fails' do
- it 'removes the remaining exported data' do
+ before do
allow_next_instance_of(Gitlab::ImportExport::Saver) do |saver|
allow(saver).to receive(:save).and_return(false)
end
+ end
+ it 'removes the remaining exported data' do
expect { service.execute }.to raise_error(Gitlab::ImportExport::Error)
expect(group.import_export_upload).to be_nil
expect(File.exist?(shared.archive_path)).to eq(false)
end
+
+ it 'notifies the user about failed group export' do
+ expect_next_instance_of(NotificationService) do |instance|
+ expect(instance).to receive(:group_was_not_exported)
+ end
+
+ expect { service.execute }.to raise_error(Gitlab::ImportExport::Error)
+ end
end
context 'when file compression fails' do
diff --git a/spec/services/groups/import_export/import_service_spec.rb b/spec/services/groups/import_export/import_service_spec.rb
index d95bba38b3e..256e0a1b3c5 100644
--- a/spec/services/groups/import_export/import_service_spec.rb
+++ b/spec/services/groups/import_export/import_service_spec.rb
@@ -3,17 +3,16 @@
require 'spec_helper'
describe Groups::ImportExport::ImportService do
- describe '#execute' do
+ context 'with group_import_ndjson feature flag disabled' do
let(:user) { create(:admin) }
let(:group) { create(:group) }
- let(:service) { described_class.new(group: group, user: user) }
- let(:import_file) { fixture_file_upload('spec/fixtures/group_export.tar.gz') }
-
let(:import_logger) { instance_double(Gitlab::Import::Logger) }
- subject { service.execute }
+ subject(:service) { described_class.new(group: group, user: user) }
before do
+ stub_feature_flags(group_import_ndjson: false)
+
ImportExportUpload.create(group: group, import_file: import_file)
allow(Gitlab::Import::Logger).to receive(:build).and_return(import_logger)
@@ -21,84 +20,227 @@ describe Groups::ImportExport::ImportService do
allow(import_logger).to receive(:info)
end
- context 'when user has correct permissions' do
- it 'imports group structure successfully' do
- expect(subject).to be_truthy
- end
+ context 'with a json file' do
+ let(:import_file) { fixture_file_upload('spec/fixtures/legacy_group_export.tar.gz') }
- it 'removes import file' do
- subject
+ it 'uses LegacyTreeRestorer to import the file' do
+ expect(Gitlab::ImportExport::Group::LegacyTreeRestorer).to receive(:new).and_call_original
- expect(group.import_export_upload.import_file.file).to be_nil
+ service.execute
end
+ end
- it 'logs the import success' do
- expect(import_logger).to receive(:info).with(
- group_id: group.id,
- group_name: group.name,
- message: 'Group Import/Export: Import succeeded'
- ).once
+ context 'with a ndjson file' do
+ let(:import_file) { fixture_file_upload('spec/fixtures/group_export.tar.gz') }
- subject
+ it 'fails to import' do
+ expect { service.execute }.to raise_error(Gitlab::ImportExport::Error, 'Incorrect JSON format')
end
end
+ end
+
+ context 'with group_import_ndjson feature flag enabled' do
+ before do
+ stub_feature_flags(group_import_ndjson: true)
+ end
+
+ context 'when importing a ndjson export' do
+ let(:user) { create(:admin) }
+ let(:group) { create(:group) }
+ let(:service) { described_class.new(group: group, user: user) }
+ let(:import_file) { fixture_file_upload('spec/fixtures/group_export.tar.gz') }
- context 'when user does not have correct permissions' do
- let(:user) { create(:user) }
+ let(:import_logger) { instance_double(Gitlab::Import::Logger) }
- it 'logs the error and raises an exception' do
- expect(import_logger).to receive(:error).with(
- group_id: group.id,
- group_name: group.name,
- message: a_string_including('Errors occurred')
- )
+ subject { service.execute }
- expect { subject }.to raise_error(Gitlab::ImportExport::Error)
+ before do
+ ImportExportUpload.create(group: group, import_file: import_file)
+
+ allow(Gitlab::Import::Logger).to receive(:build).and_return(import_logger)
+ allow(import_logger).to receive(:error)
+ allow(import_logger).to receive(:info)
end
- it 'tracks the error' do
- shared = Gitlab::ImportExport::Shared.new(group)
- allow(Gitlab::ImportExport::Shared).to receive(:new).and_return(shared)
+ context 'when user has correct permissions' do
+ it 'imports group structure successfully' do
+ expect(subject).to be_truthy
+ end
+
+ it 'removes import file' do
+ subject
- expect(shared).to receive(:error) do |param|
- expect(param.message).to include 'does not have required permissions for'
+ expect(group.import_export_upload.import_file.file).to be_nil
end
- expect { subject }.to raise_error(Gitlab::ImportExport::Error)
+ it 'logs the import success' do
+ expect(import_logger).to receive(:info).with(
+ group_id: group.id,
+ group_name: group.name,
+ message: 'Group Import/Export: Import succeeded'
+ ).once
+
+ subject
+ end
end
- end
- context 'when there are errors with the import file' do
- let(:import_file) { fixture_file_upload('spec/fixtures/symlink_export.tar.gz') }
+ context 'when user does not have correct permissions' do
+ let(:user) { create(:user) }
- it 'logs the error and raises an exception' do
- expect(import_logger).to receive(:error).with(
- group_id: group.id,
- group_name: group.name,
- message: a_string_including('Errors occurred')
- ).once
+ it 'logs the error and raises an exception' do
+ expect(import_logger).to receive(:error).with(
+ group_id: group.id,
+ group_name: group.name,
+ message: a_string_including('Errors occurred')
+ )
- expect { subject }.to raise_error(Gitlab::ImportExport::Error)
+ expect { subject }.to raise_error(Gitlab::ImportExport::Error)
+ end
+
+ it 'tracks the error' do
+ shared = Gitlab::ImportExport::Shared.new(group)
+ allow(Gitlab::ImportExport::Shared).to receive(:new).and_return(shared)
+
+ expect(shared).to receive(:error) do |param|
+ expect(param.message).to include 'does not have required permissions for'
+ end
+
+ expect { subject }.to raise_error(Gitlab::ImportExport::Error)
+ end
end
- end
- context 'when there are errors with the sub-relations' do
- let(:import_file) { fixture_file_upload('spec/fixtures/group_export_invalid_subrelations.tar.gz') }
+ context 'when there are errors with the import file' do
+ let(:import_file) { fixture_file_upload('spec/fixtures/symlink_export.tar.gz') }
+
+ it 'logs the error and raises an exception' do
+ expect(import_logger).to receive(:error).with(
+ group_id: group.id,
+ group_name: group.name,
+ message: a_string_including('Errors occurred')
+ ).once
+
+ expect { subject }.to raise_error(Gitlab::ImportExport::Error)
+ end
+ end
+
+ context 'when there are errors with the sub-relations' do
+ let(:import_file) { fixture_file_upload('spec/fixtures/group_export_invalid_subrelations.tar.gz') }
+
+ it 'successfully imports the group' do
+ expect(subject).to be_truthy
+ end
+
+ it 'logs the import success' do
+ allow(Gitlab::Import::Logger).to receive(:build).and_return(import_logger)
+
+ expect(import_logger).to receive(:info).with(
+ group_id: group.id,
+ group_name: group.name,
+ message: 'Group Import/Export: Import succeeded'
+ )
- it 'successfully imports the group' do
- expect(subject).to be_truthy
+ subject
+ end
end
+ end
+
+ context 'when importing a json export' do
+ let(:user) { create(:admin) }
+ let(:group) { create(:group) }
+ let(:service) { described_class.new(group: group, user: user) }
+ let(:import_file) { fixture_file_upload('spec/fixtures/legacy_group_export.tar.gz') }
+
+ let(:import_logger) { instance_double(Gitlab::Import::Logger) }
+
+ subject { service.execute }
+
+ before do
+ ImportExportUpload.create(group: group, import_file: import_file)
- it 'logs the import success' do
allow(Gitlab::Import::Logger).to receive(:build).and_return(import_logger)
+ allow(import_logger).to receive(:error)
+ allow(import_logger).to receive(:info)
+ end
- expect(import_logger).to receive(:info).with(
- group_id: group.id,
- group_name: group.name,
- message: 'Group Import/Export: Import succeeded'
- )
+ context 'when user has correct permissions' do
+ it 'imports group structure successfully' do
+ expect(subject).to be_truthy
+ end
+
+ it 'removes import file' do
+ subject
+
+ expect(group.import_export_upload.import_file.file).to be_nil
+ end
+
+ it 'logs the import success' do
+ expect(import_logger).to receive(:info).with(
+ group_id: group.id,
+ group_name: group.name,
+ message: 'Group Import/Export: Import succeeded'
+ ).once
+
+ subject
+ end
+ end
+
+ context 'when user does not have correct permissions' do
+ let(:user) { create(:user) }
+
+ it 'logs the error and raises an exception' do
+ expect(import_logger).to receive(:error).with(
+ group_id: group.id,
+ group_name: group.name,
+ message: a_string_including('Errors occurred')
+ )
+
+ expect { subject }.to raise_error(Gitlab::ImportExport::Error)
+ end
- subject
+ it 'tracks the error' do
+ shared = Gitlab::ImportExport::Shared.new(group)
+ allow(Gitlab::ImportExport::Shared).to receive(:new).and_return(shared)
+
+ expect(shared).to receive(:error) do |param|
+ expect(param.message).to include 'does not have required permissions for'
+ end
+
+ expect { subject }.to raise_error(Gitlab::ImportExport::Error)
+ end
+ end
+
+ context 'when there are errors with the import file' do
+ let(:import_file) { fixture_file_upload('spec/fixtures/legacy_symlink_export.tar.gz') }
+
+ it 'logs the error and raises an exception' do
+ expect(import_logger).to receive(:error).with(
+ group_id: group.id,
+ group_name: group.name,
+ message: a_string_including('Errors occurred')
+ ).once
+
+ expect { subject }.to raise_error(Gitlab::ImportExport::Error)
+ end
+ end
+
+ context 'when there are errors with the sub-relations' do
+ let(:import_file) { fixture_file_upload('spec/fixtures/legacy_group_export_invalid_subrelations.tar.gz') }
+
+ it 'successfully imports the group' do
+ expect(subject).to be_truthy
+ end
+
+ it 'logs the import success' do
+ allow(Gitlab::Import::Logger).to receive(:build).and_return(import_logger)
+
+ expect(import_logger).to receive(:info).with(
+ group_id: group.id,
+ group_name: group.name,
+ message: 'Group Import/Export: Import succeeded'
+ )
+
+ subject
+ end
end
end
end
diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb
index 1aa7e06182b..b17d78505d1 100644
--- a/spec/services/groups/update_service_spec.rb
+++ b/spec/services/groups/update_service_spec.rb
@@ -148,6 +148,26 @@ describe Groups::UpdateService do
end
end
+ context 'updating default_branch_protection' do
+ let(:service) do
+ described_class.new(internal_group, user, default_branch_protection: Gitlab::Access::PROTECTION_NONE)
+ end
+
+ context 'for users who have the ability to update default_branch_protection' do
+ it 'updates the attribute' do
+ internal_group.add_owner(user)
+
+ expect { service.execute }.to change { internal_group.default_branch_protection }.to(Gitlab::Access::PROTECTION_NONE)
+ end
+ end
+
+ context 'for users who do not have the ability to update default_branch_protection' do
+ it 'does not update the attribute' do
+ expect { service.execute }.not_to change { internal_group.default_branch_protection }
+ end
+ end
+ end
+
context 'rename group' do
let!(:service) { described_class.new(internal_group, user, path: SecureRandom.hex) }
diff --git a/spec/services/incident_management/create_issue_service_spec.rb b/spec/services/incident_management/create_issue_service_spec.rb
index 4c7fb682193..5a3721f00b8 100644
--- a/spec/services/incident_management/create_issue_service_spec.rb
+++ b/spec/services/incident_management/create_issue_service_spec.rb
@@ -6,7 +6,7 @@ describe IncidentManagement::CreateIssueService do
let(:project) { create(:project, :repository, :private) }
let_it_be(:user) { User.alert_bot }
let(:service) { described_class.new(project, alert_payload) }
- let(:alert_starts_at) { Time.now }
+ let(:alert_starts_at) { Time.current }
let(:alert_title) { 'TITLE' }
let(:alert_annotations) { { title: alert_title } }
@@ -281,18 +281,28 @@ describe IncidentManagement::CreateIssueService do
setting.update!(create_issue: false)
end
- it 'returns an error' do
- expect(service)
- .to receive(:log_error)
- .with(error_message('setting disabled'))
+ context 'when skip_settings_check is false (default)' do
+ it 'returns an error' do
+ expect(service)
+ .to receive(:log_error)
+ .with(error_message('setting disabled'))
+
+ expect(subject).to eq(status: :error, message: 'setting disabled')
+ end
+ end
+
+ context 'when skip_settings_check is true' do
+ subject { service.execute(skip_settings_check: true) }
- expect(subject).to eq(status: :error, message: 'setting disabled')
+ it 'creates an issue' do
+ expect { subject }.to change(Issue, :count).by(1)
+ end
end
end
private
- def build_alert_payload(annotations: {}, starts_at: Time.now)
+ def build_alert_payload(annotations: {}, starts_at: Time.current)
{
'annotations' => annotations.stringify_keys
}.tap do |payload|
diff --git a/spec/services/issuable/clone/attributes_rewriter_spec.rb b/spec/services/issuable/clone/attributes_rewriter_spec.rb
index 9111b19d7b7..fb520f828fa 100644
--- a/spec/services/issuable/clone/attributes_rewriter_spec.rb
+++ b/spec/services/issuable/clone/attributes_rewriter_spec.rb
@@ -89,7 +89,7 @@ describe Issuable::Clone::AttributesRewriter do
create_event(milestone1_project1)
create_event(milestone2_project1)
- create_event(milestone1_project1, 'remove')
+ create_event(nil, 'remove')
create_event(milestone3_project1)
end
@@ -101,7 +101,7 @@ describe Issuable::Clone::AttributesRewriter do
expect_milestone_event(new_issue_milestone_events.first, milestone: milestone1_project2, action: 'add', state: 'opened')
expect_milestone_event(new_issue_milestone_events.second, milestone: milestone2_project2, action: 'add', state: 'opened')
- expect_milestone_event(new_issue_milestone_events.third, milestone: milestone1_project2, action: 'remove', state: 'opened')
+ expect_milestone_event(new_issue_milestone_events.third, milestone: nil, action: 'remove', state: 'opened')
end
def create_event(milestone, action = 'add')
@@ -109,10 +109,32 @@ describe Issuable::Clone::AttributesRewriter do
end
def expect_milestone_event(event, expected_attrs)
- expect(event.milestone_id).to eq(expected_attrs[:milestone].id)
+ expect(event.milestone_id).to eq(expected_attrs[:milestone]&.id)
expect(event.action).to eq(expected_attrs[:action])
expect(event.state).to eq(expected_attrs[:state])
end
end
+
+ context 'with existing state events' do
+ let!(:event1) { create(:resource_state_event, issue: original_issue, state: 'opened') }
+ let!(:event2) { create(:resource_state_event, issue: original_issue, state: 'closed') }
+ let!(:event3) { create(:resource_state_event, issue: original_issue, state: 'reopened') }
+
+ it 'copies existing state events as expected' do
+ subject.execute
+
+ state_events = new_issue.reload.resource_state_events
+ expect(state_events.size).to eq(3)
+
+ expect_state_event(state_events.first, issue: new_issue, state: 'opened')
+ expect_state_event(state_events.second, issue: new_issue, state: 'closed')
+ expect_state_event(state_events.third, issue: new_issue, state: 'reopened')
+ end
+
+ def expect_state_event(event, expected_attrs)
+ expect(event.issue_id).to eq(expected_attrs[:issue]&.id)
+ expect(event.state).to eq(expected_attrs[:state])
+ end
+ end
end
end
diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb
index 86377e054c1..6fc1928d47b 100644
--- a/spec/services/issues/close_service_spec.rb
+++ b/spec/services/issues/close_service_spec.rb
@@ -146,7 +146,7 @@ describe Issues::CloseService do
context 'when `metrics.first_mentioned_in_commit_at` is already set' do
before do
- issue.metrics.update!(first_mentioned_in_commit_at: Time.now)
+ issue.metrics.update!(first_mentioned_in_commit_at: Time.current)
end
it 'does not update the metrics' do
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index bd50d6b1001..7a251e03e51 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -110,6 +110,31 @@ describe Issues::CreateService do
end
end
+ context 'when labels is nil' do
+ let(:opts) do
+ { title: 'Title',
+ description: 'Description',
+ labels: nil }
+ end
+
+ it 'does not assign label' do
+ expect(issue.labels).to be_empty
+ end
+ end
+
+ context 'when labels is nil and label_ids is present' do
+ let(:opts) do
+ { title: 'Title',
+ description: 'Description',
+ labels: nil,
+ label_ids: labels.map(&:id) }
+ end
+
+ it 'assigns group labels' do
+ expect(issue.labels).to match_array labels
+ end
+ end
+
context 'when milestone belongs to different project' do
let(:milestone) { create(:milestone) }
@@ -368,6 +393,8 @@ describe Issues::CreateService do
end
context 'checking spam' do
+ include_context 'includes Spam constants'
+
let(:title) { 'Legit issue' }
let(:description) { 'please fix' }
let(:opts) do
@@ -378,11 +405,13 @@ describe Issues::CreateService do
}
end
+ subject { described_class.new(project, user, opts) }
+
before do
stub_feature_flags(allow_possible_spam: false)
end
- context 'when recaptcha was verified' do
+ context 'when reCAPTCHA was verified' do
let(:log_user) { user }
let(:spam_logs) { create_list(:spam_log, 2, user: log_user, title: title) }
let(:target_spam_log) { spam_logs.last }
@@ -391,7 +420,7 @@ describe Issues::CreateService do
opts[:recaptcha_verified] = true
opts[:spam_log_id] = target_spam_log.id
- expect(Spam::AkismetService).not_to receive(:new)
+ expect(Spam::SpamVerdictService).not_to receive(:new)
end
it 'does not mark an issue as spam' do
@@ -402,7 +431,7 @@ describe Issues::CreateService do
expect(issue).to be_valid
end
- it 'does not assign a spam_log to an issue' do
+ it 'does not assign a spam_log to the issue' do
expect(issue.spam_log).to be_nil
end
@@ -419,17 +448,42 @@ describe Issues::CreateService do
end
end
- context 'when recaptcha was not verified' do
+ context 'when reCAPTCHA was not verified' do
before do
- expect_next_instance_of(Spam::SpamCheckService) do |spam_service|
+ expect_next_instance_of(Spam::SpamActionService) do |spam_service|
expect(spam_service).to receive_messages(check_for_spam?: true)
end
end
- context 'when akismet detects spam' do
+ context 'when SpamVerdictService requires reCAPTCHA' do
before do
- expect_next_instance_of(Spam::AkismetService) do |akismet_service|
- expect(akismet_service).to receive_messages(spam?: true)
+ expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ expect(verdict_service).to receive(:execute).and_return(REQUIRE_RECAPTCHA)
+ end
+ end
+
+ it 'does not mark the issue as spam' do
+ expect(issue).not_to be_spam
+ end
+
+ it 'marks the issue as needing reCAPTCHA' do
+ expect(issue.needs_recaptcha?).to be_truthy
+ end
+
+ it 'invalidates the issue' do
+ expect(issue).to be_invalid
+ end
+
+ it 'creates a new spam_log' do
+ expect { issue }
+ .to have_spam_log(title: title, description: description, user_id: user.id, noteable_type: 'Issue')
+ end
+ end
+
+ context 'when SpamVerdictService disallows creation' do
+ before do
+ expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ expect(verdict_service).to receive(:execute).and_return(DISALLOW)
end
end
@@ -438,6 +492,10 @@ describe Issues::CreateService do
expect(issue).to be_spam
end
+ it 'does not mark the issue as needing reCAPTCHA' do
+ expect(issue.needs_recaptcha?).to be_falsey
+ end
+
it 'invalidates the issue' do
expect(issue).to be_invalid
end
@@ -457,7 +515,11 @@ describe Issues::CreateService do
expect(issue).not_to be_spam
end
- it '​creates a valid issue' do
+ it 'does not mark the issue as needing reCAPTCHA' do
+ expect(issue.needs_recaptcha?).to be_falsey
+ end
+
+ it 'creates a valid issue' do
expect(issue).to be_valid
end
@@ -468,10 +530,10 @@ describe Issues::CreateService do
end
end
- context 'when akismet does not detect spam' do
+ context 'when the SpamVerdictService allows creation' do
before do
- expect_next_instance_of(Spam::AkismetService) do |akismet_service|
- expect(akismet_service).to receive_messages(spam?: false)
+ expect_next_instance_of(Spam::SpamVerdictService) do |verdict_service|
+ expect(verdict_service).to receive(:execute).and_return(ALLOW)
end
end
diff --git a/spec/services/issues/related_branches_service_spec.rb b/spec/services/issues/related_branches_service_spec.rb
index eae35f12560..9f72e499414 100644
--- a/spec/services/issues/related_branches_service_spec.rb
+++ b/spec/services/issues/related_branches_service_spec.rb
@@ -3,39 +3,103 @@
require 'spec_helper'
describe Issues::RelatedBranchesService do
- let(:user) { create(:admin) }
- let(:issue) { create(:issue) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:issue) { create(:issue) }
+ let(:user) { developer }
subject { described_class.new(issue.project, user) }
+ before do
+ issue.project.add_developer(developer)
+ end
+
describe '#execute' do
+ let(:sha) { 'abcdef' }
+ let(:repo) { issue.project.repository }
+ let(:project) { issue.project }
+ let(:branch_info) { subject.execute(issue) }
+
+ def make_branch
+ double('Branch', dereferenced_target: double('Target', sha: sha))
+ end
+
before do
- allow(issue.project.repository).to receive(:branch_names).and_return(["mpempe", "#{issue.iid}mepmep", issue.to_branch_name, "#{issue.iid}-branch"])
+ allow(repo).to receive(:branch_names).and_return(branch_names)
end
- it "selects the right branches when there are no referenced merge requests" do
- expect(subject.execute(issue)).to eq([issue.to_branch_name, "#{issue.iid}-branch"])
+ context 'no branches are available' do
+ let(:branch_names) { [] }
+
+ it 'returns an empty array' do
+ expect(branch_info).to be_empty
+ end
end
- it "selects the right branches when there is a referenced merge request" do
- merge_request = create(:merge_request, { description: "Closes ##{issue.iid}",
- source_project: issue.project,
- source_branch: "#{issue.iid}-branch" })
- merge_request.create_cross_references!(user)
+ context 'branches are available' do
+ let(:missing_branch) { "#{issue.to_branch_name}-missing" }
+ let(:unreadable_branch_name) { "#{issue.to_branch_name}-unreadable" }
+ let(:pipeline) { build(:ci_pipeline, :success, project: project) }
+ let(:unreadable_pipeline) { build(:ci_pipeline, :running) }
+
+ let(:branch_names) do
+ [
+ generate(:branch),
+ "#{issue.iid}doesnt-match",
+ issue.to_branch_name,
+ missing_branch,
+ unreadable_branch_name
+ ]
+ end
+
+ before do
+ {
+ issue.to_branch_name => pipeline,
+ 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)
+ end
+
+ allow(repo).to receive(:find_branch).with(missing_branch).and_return(nil)
+ end
+
+ it 'selects relevant branches, along with pipeline status where available' do
+ expect(branch_info).to contain_exactly(
+ { name: issue.to_branch_name, pipeline_status: an_instance_of(Gitlab::Ci::Status::Success) },
+ { name: missing_branch, pipeline_status: be_nil },
+ { name: unreadable_branch_name, pipeline_status: be_nil }
+ )
+ end
+
+ context 'the user has access to otherwise unreadable pipelines' do
+ let(:user) { create(:admin) }
+
+ it 'returns info a developer could not see' do
+ expect(branch_info.pluck(:pipeline_status)).to include(an_instance_of(Gitlab::Ci::Status::Running))
+ end
+ end
+
+ it 'excludes branches referenced in merge requests' do
+ merge_request = create(:merge_request, { description: "Closes #{issue.to_reference}",
+ source_project: issue.project,
+ source_branch: issue.to_branch_name })
+ merge_request.create_cross_references!(user)
- referenced_merge_requests = Issues::ReferencedMergeRequestsService
- .new(issue.project, user)
- .referenced_merge_requests(issue)
+ referenced_merge_requests = Issues::ReferencedMergeRequestsService
+ .new(issue.project, user)
+ .referenced_merge_requests(issue)
- expect(referenced_merge_requests).not_to be_empty
- expect(subject.execute(issue)).to eq([issue.to_branch_name])
+ expect(referenced_merge_requests).not_to be_empty
+ expect(branch_info.pluck(:name)).not_to include(merge_request.source_branch)
+ end
end
- it 'excludes stable branches from the related branches' do
- allow(issue.project.repository).to receive(:branch_names)
- .and_return(["#{issue.iid}-0-stable"])
+ context 'one of the branches is stable' do
+ let(:branch_names) { ["#{issue.iid}-0-stable"] }
- expect(subject.execute(issue)).to eq []
+ it 'is excluded' do
+ expect(branch_info).to be_empty
+ end
end
end
end
diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb
index f12a3820b8d..ec6624db6fc 100644
--- a/spec/services/issues/resolve_discussions_spec.rb
+++ b/spec/services/issues/resolve_discussions_spec.rb
@@ -3,19 +3,20 @@
require 'spec_helper.rb'
describe Issues::ResolveDiscussions do
- class DummyService < Issues::BaseService
- include ::Issues::ResolveDiscussions
-
- def initialize(*args)
- super
- filter_resolve_discussion_params
- end
- end
-
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
before do
+ stub_const('DummyService', Class.new(Issues::BaseService))
+ DummyService.class_eval do
+ include ::Issues::ResolveDiscussions
+
+ def initialize(*args)
+ super
+ filter_resolve_discussion_params
+ end
+ end
+
project.add_developer(user)
end
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index c32bef5a1a5..80039049bc3 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -510,7 +510,7 @@ describe Issues::UpdateService, :mailer do
end
it 'updates updated_at' do
- expect(issue.reload.updated_at).to be > Time.now
+ expect(issue.reload.updated_at).to be > Time.current
end
end
end
@@ -842,5 +842,33 @@ describe Issues::UpdateService, :mailer do
let(:open_issuable) { issue }
let(:closed_issuable) { create(:closed_issue, project: project) }
end
+
+ context 'real-time updates' do
+ let(:update_params) { { assignee_ids: [user2.id] } }
+
+ context 'when broadcast_issue_updates is enabled' do
+ before do
+ stub_feature_flags(broadcast_issue_updates: true)
+ end
+
+ it 'broadcasts to the issues channel' do
+ expect(IssuesChannel).to receive(:broadcast_to).with(issue, event: 'updated')
+
+ update_issue(update_params)
+ end
+ end
+
+ context 'when broadcast_issue_updates is disabled' do
+ before do
+ stub_feature_flags(broadcast_issue_updates: false)
+ end
+
+ it 'does not broadcast to the issues channel' do
+ expect(IssuesChannel).not_to receive(:broadcast_to)
+
+ update_issue(update_params)
+ end
+ end
+ end
end
end
diff --git a/spec/services/jira_import/start_import_service_spec.rb b/spec/services/jira_import/start_import_service_spec.rb
index 90f38945a9f..759e4f3363f 100644
--- a/spec/services/jira_import/start_import_service_spec.rb
+++ b/spec/services/jira_import/start_import_service_spec.rb
@@ -3,113 +3,89 @@
require 'spec_helper'
describe JiraImport::StartImportService do
+ include JiraServiceHelper
+
let_it_be(:user) { create(:user) }
let_it_be(:project, reload: true) { create(:project) }
let(:key) { 'KEY' }
subject { described_class.new(user, project, key).execute }
- context 'when feature flag disabled' do
+ context 'when an error is returned from the project validation' do
before do
- stub_feature_flags(jira_issue_import: false)
+ allow(project).to receive(:validate_jira_import_settings!)
+ .and_raise(Projects::ImportService::Error, 'Jira import feature is disabled.')
end
it_behaves_like 'responds with error', 'Jira import feature is disabled.'
end
- context 'when feature flag enabled' do
+ context 'when project validation is ok' do
+ let!(:jira_service) { create(:jira_service, project: project, active: true) }
+
before do
- stub_feature_flags(jira_issue_import: true)
+ stub_jira_service_test
+ allow(project).to receive(:validate_jira_import_settings!)
end
- context 'when user does not have permissions to run the import' do
- before do
- create(:jira_service, project: project, active: true)
+ context 'when Jira project key is not provided' do
+ let(:key) { '' }
- project.add_developer(user)
- end
-
- it_behaves_like 'responds with error', 'You do not have permissions to run the import.'
+ it_behaves_like 'responds with error', 'Unable to find Jira project to import data from.'
end
- context 'when user has permission to run import' do
- before do
- project.add_maintainer(user)
- end
+ context 'when correct data provided' do
+ let(:fake_key) { 'some-key' }
- context 'when Jira service was not setup' do
- it_behaves_like 'responds with error', 'Jira integration not configured.'
- end
+ subject { described_class.new(user, project, fake_key).execute }
- context 'when Jira service exists' do
- let!(:jira_service) { create(:jira_service, project: project, active: true) }
+ context 'when import is already running' do
+ let_it_be(:jira_import_state) { create(:jira_import_state, :started, project: project) }
- context 'when Jira project key is not provided' do
- let(:key) { '' }
+ it_behaves_like 'responds with error', 'Jira import is already running.'
+ end
- it_behaves_like 'responds with error', 'Unable to find Jira project to import data from.'
+ context 'when everything is ok' do
+ it 'returns success response' do
+ expect(subject).to be_a(ServiceResponse)
+ expect(subject).to be_success
end
- context 'when issues feature are disabled' do
- let_it_be(:project, reload: true) { create(:project, :issues_disabled) }
+ it 'schedules Jira import' do
+ subject
- it_behaves_like 'responds with error', 'Cannot import because issues are not available in this project.'
+ expect(project.latest_jira_import).to be_scheduled
end
- context 'when correct data provided' do
- let(:fake_key) { 'some-key' }
-
- subject { described_class.new(user, project, fake_key).execute }
-
- context 'when import is already running' do
- let_it_be(:jira_import_state) { create(:jira_import_state, :started, project: project) }
+ it 'creates Jira import data' do
+ jira_import = subject.payload[:import_data]
- it_behaves_like 'responds with error', 'Jira import is already running.'
- end
-
- context 'when everything is ok' do
- it 'returns success response' do
- expect(subject).to be_a(ServiceResponse)
- expect(subject).to be_success
- end
-
- it 'schedules jira import' do
- subject
-
- expect(project.latest_jira_import).to be_scheduled
- end
- end
-
- it 'creates jira import data' do
- jira_import = subject.payload[:import_data]
-
- expect(jira_import.jira_project_xid).to eq(0)
- expect(jira_import.jira_project_name).to eq(fake_key)
- expect(jira_import.jira_project_key).to eq(fake_key)
- expect(jira_import.user).to eq(user)
- end
+ expect(jira_import.jira_project_xid).to eq(0)
+ expect(jira_import.jira_project_name).to eq(fake_key)
+ expect(jira_import.jira_project_key).to eq(fake_key)
+ expect(jira_import.user).to eq(user)
+ end
- it 'creates jira import label' do
- expect { subject }.to change { Label.count }.by(1)
- end
+ it 'creates Jira import label' do
+ expect { subject }.to change { Label.count }.by(1)
+ end
- it 'creates jira label title with correct number' do
- jira_import = subject.payload[:import_data]
+ it 'creates Jira label title with correct number' do
+ jira_import = subject.payload[:import_data]
- label_title = "jira-import::#{jira_import.jira_project_key}-1"
- expect(jira_import.label.title).to eq(label_title)
- end
+ label_title = "jira-import::#{jira_import.jira_project_key}-1"
+ expect(jira_import.label.title).to eq(label_title)
+ end
+ end
- context 'when multiple jira imports for same jira project' do
- let!(:jira_imports) { create_list(:jira_import_state, 3, :finished, project: project, jira_project_key: fake_key)}
+ context 'when multiple Jira imports for same Jira project' do
+ let!(:jira_imports) { create_list(:jira_import_state, 3, :finished, project: project, jira_project_key: fake_key)}
- it 'creates jira label title with correct number' do
- jira_import = subject.payload[:import_data]
+ it 'creates Jira label title with correct number' do
+ jira_import = subject.payload[:import_data]
- label_title = "jira-import::#{jira_import.jira_project_key}-4"
- expect(jira_import.label.title).to eq(label_title)
- end
- end
+ label_title = "jira-import::#{jira_import.jira_project_key}-4"
+ expect(jira_import.label.title).to eq(label_title)
end
end
end
diff --git a/spec/services/lfs/file_transformer_spec.rb b/spec/services/lfs/file_transformer_spec.rb
index 9973d64930b..13d9c369c42 100644
--- a/spec/services/lfs/file_transformer_spec.rb
+++ b/spec/services/lfs/file_transformer_spec.rb
@@ -81,6 +81,23 @@ describe Lfs::FileTransformer do
expect(LfsObject.last.file.read).to eq file_content
end
+
+ context 'when repository is a design repository' do
+ let(:file_path) { "/#{DesignManagement.designs_directory}/test_file.lfs" }
+ let(:repository) { project.design_repository }
+
+ it "creates an LfsObject with the file's content" do
+ subject.new_file(file_path, file)
+
+ expect(LfsObject.last.file.read).to eq(file_content)
+ end
+
+ it 'saves the correct repository_type to LfsObjectsProject' do
+ subject.new_file(file_path, file)
+
+ expect(project.lfs_objects_projects.first.repository_type).to eq('design')
+ end
+ end
end
context "when doesn't use LFS" do
diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb
index dc34546a599..9155db16d17 100644
--- a/spec/services/merge_requests/create_service_spec.rb
+++ b/spec/services/merge_requests/create_service_spec.rb
@@ -224,19 +224,6 @@ describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
end
end
- context 'when ci_use_merge_request_ref feature flag is false' do
- before do
- stub_feature_flags(ci_use_merge_request_ref: false)
- end
-
- it 'create legacy detached merge request pipeline for non-fork merge request' do
- merge_request.reload
-
- expect(merge_request.actual_head_pipeline)
- .to be_legacy_detached_merge_request_pipeline
- end
- end
-
context 'when there are no commits between source branch and target branch' do
let(:opts) do
{
diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb
index fa7f745d8a0..bcad822b1dc 100644
--- a/spec/services/merge_requests/merge_service_spec.rb
+++ b/spec/services/merge_requests/merge_service_spec.rb
@@ -118,7 +118,7 @@ describe MergeRequests::MergeService do
it 'closes GitLab issue tracker issues' do
issue = create :issue, project: project
- commit = instance_double('commit', safe_message: "Fixes #{issue.to_reference}", date: Time.now, authored_date: Time.now)
+ commit = instance_double('commit', safe_message: "Fixes #{issue.to_reference}", date: Time.current, authored_date: Time.current)
allow(merge_request).to receive(:commits).and_return([commit])
merge_request.cache_merge_request_closes_issues!
diff --git a/spec/services/merge_requests/rebase_service_spec.rb b/spec/services/merge_requests/rebase_service_spec.rb
index 22df3b84243..69d555f838d 100644
--- a/spec/services/merge_requests/rebase_service_spec.rb
+++ b/spec/services/merge_requests/rebase_service_spec.rb
@@ -72,12 +72,15 @@ describe MergeRequests::RebaseService do
it_behaves_like 'sequence of failure and success'
context 'when unexpected error occurs' do
+ let(:exception) { RuntimeError.new('Something went wrong') }
+ let(:merge_request_ref) { merge_request.to_reference(full: true) }
+
before do
- allow(repository).to receive(:gitaly_operation_client).and_raise('Something went wrong')
+ allow(repository).to receive(:gitaly_operation_client).and_raise(exception)
end
it 'saves a generic error message' do
- subject.execute(merge_request)
+ service.execute(merge_request)
expect(merge_request.reload.merge_error).to eq(described_class::REBASE_ERROR)
end
@@ -86,6 +89,18 @@ describe MergeRequests::RebaseService do
expect(service.execute(merge_request)).to match(status: :error,
message: described_class::REBASE_ERROR)
end
+
+ it 'logs the error' do
+ expect(service).to receive(:log_error).with(exception: exception, message: described_class::REBASE_ERROR, save_message_on_model: true).and_call_original
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(exception,
+ class: described_class.to_s,
+ merge_request: merge_request_ref,
+ merge_request_id: merge_request.id,
+ message: described_class::REBASE_ERROR,
+ save_message_on_model: true).and_call_original
+
+ service.execute(merge_request)
+ end
end
context 'with git command failure' do
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index 4f052fa3edb..94e65d895ac 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -94,6 +94,31 @@ describe MergeRequests::RefreshService do
expect(@fork_build_failed_todo).to be_done
end
+ context 'when a merge error exists' do
+ let(:error_message) { 'This is a merge error' }
+
+ before do
+ @merge_request = create(:merge_request,
+ source_project: @project,
+ source_branch: 'feature',
+ target_branch: 'master',
+ target_project: @project,
+ merge_error: error_message)
+ end
+
+ it 'clears merge errors when pushing to the source branch' do
+ expect { refresh_service.execute(@oldrev, @newrev, 'refs/heads/feature') }
+ .to change { @merge_request.reload.merge_error }
+ .from(error_message)
+ .to(nil)
+ end
+
+ it 'does not clear merge errors when pushing to the target branch' do
+ expect { refresh_service.execute(@oldrev, @newrev, 'refs/heads/master') }
+ .not_to change { @merge_request.reload.merge_error }
+ end
+ end
+
it 'reloads source branch MRs memoization' do
refresh_service.execute(@oldrev, @newrev, 'refs/heads/master')
@@ -209,19 +234,6 @@ describe MergeRequests::RefreshService do
end
end
- context 'when ci_use_merge_request_ref feature flag is false' do
- before do
- stub_feature_flags(ci_use_merge_request_ref: false)
- end
-
- it 'create legacy detached merge request pipeline for non-fork merge request' do
- subject
-
- expect(@merge_request.pipelines_for_merge_request.first)
- .to be_legacy_detached_merge_request_pipeline
- end
- end
-
context "when branch pipeline was created before a detaced merge request pipeline has been created" do
before do
create(:ci_pipeline, project: @merge_request.source_project,
@@ -623,7 +635,7 @@ describe MergeRequests::RefreshService do
references: [issue],
author_name: commit_author.name,
author_email: commit_author.email,
- committed_date: Time.now
+ committed_date: Time.current
)
allow_any_instance_of(MergeRequest).to receive(:commits).and_return(CommitCollection.new(@project, [commit], 'feature'))
diff --git a/spec/services/merge_requests/squash_service_spec.rb b/spec/services/merge_requests/squash_service_spec.rb
index cb278eec692..a53314ed737 100644
--- a/spec/services/merge_requests/squash_service_spec.rb
+++ b/spec/services/merge_requests/squash_service_spec.rb
@@ -141,15 +141,14 @@ describe MergeRequests::SquashService do
let(:merge_request) { merge_request_with_only_new_files }
let(:error) { 'A test error' }
- context 'with gitaly enabled' do
+ context 'with an error in Gitaly UserSquash RPC' do
before do
allow(repository.gitaly_operation_client).to receive(:user_squash)
.and_raise(Gitlab::Git::Repository::GitError, error)
end
- it 'logs the stage and output' do
- expect(service).to receive(:log_error).with(log_error)
- expect(service).to receive(:log_error).with(error)
+ it 'logs the error' do
+ expect(service).to receive(:log_error).with(exception: an_instance_of(Gitlab::Git::Repository::GitError), message: 'Failed to squash merge request')
service.execute
end
@@ -158,19 +157,42 @@ describe MergeRequests::SquashService do
expect(service.execute).to match(status: :error, message: a_string_including('squash'))
end
end
+
+ context 'with an error in squash in progress check' do
+ before do
+ allow(repository).to receive(:squash_in_progress?)
+ .and_raise(Gitlab::Git::Repository::GitError, error)
+ end
+
+ it 'logs the stage and output' do
+ expect(service).to receive(:log_error).with(exception: an_instance_of(Gitlab::Git::Repository::GitError), message: 'Failed to check squash in progress')
+
+ service.execute
+ end
+
+ it 'returns an error' do
+ expect(service.execute).to match(status: :error, message: 'An error occurred while checking whether another squash is in progress.')
+ end
+ end
end
context 'when any other exception is thrown' do
let(:merge_request) { merge_request_with_only_new_files }
- let(:error) { 'A test error' }
+ let(:merge_request_ref) { merge_request.to_reference(full: true) }
+ let(:exception) { RuntimeError.new('A test error') }
before do
- allow(merge_request.target_project.repository).to receive(:squash).and_raise(error)
+ allow(merge_request.target_project.repository).to receive(:squash).and_raise(exception)
end
- it 'logs the MR reference and exception' do
- expect(service).to receive(:log_error).with(a_string_including("#{project.full_path}#{merge_request.to_reference}"))
- expect(service).to receive(:log_error).with(error)
+ it 'logs the error' do
+ expect(service).to receive(:log_error).with(exception: exception, message: 'Failed to squash merge request').and_call_original
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(exception,
+ class: described_class.to_s,
+ merge_request: merge_request_ref,
+ merge_request_id: merge_request.id,
+ message: 'Failed to squash merge request',
+ save_message_on_model: false).and_call_original
service.execute
end
diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb
index 8c1800c495f..2b934b24757 100644
--- a/spec/services/merge_requests/update_service_spec.rb
+++ b/spec/services/merge_requests/update_service_spec.rb
@@ -92,6 +92,7 @@ describe MergeRequests::UpdateService, :mailer do
labels: [],
mentioned_users: [user2],
assignees: [user3],
+ milestone: nil,
total_time_spent: 0,
description: "FYI #{user2.to_reference}"
}
@@ -452,7 +453,7 @@ describe MergeRequests::UpdateService, :mailer do
end
it 'updates updated_at' do
- expect(merge_request.reload.updated_at).to be > Time.now
+ expect(merge_request.reload.updated_at).to be > Time.current
end
end
diff --git a/spec/services/metrics/dashboard/clone_dashboard_service_spec.rb b/spec/services/metrics/dashboard/clone_dashboard_service_spec.rb
index b386159541a..3d26ab2ede5 100644
--- a/spec/services/metrics/dashboard/clone_dashboard_service_spec.rb
+++ b/spec/services/metrics/dashboard/clone_dashboard_service_spec.rb
@@ -5,8 +5,6 @@ require 'spec_helper'
describe Metrics::Dashboard::CloneDashboardService, :use_clean_rails_memory_store_caching do
include MetricsDashboardHelpers
- STAGES = ::Gitlab::Metrics::Dashboard::Stages
-
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:environment) { create(:environment, project: project) }
@@ -83,7 +81,7 @@ describe Metrics::Dashboard::CloneDashboardService, :use_clean_rails_memory_stor
allow(::Gitlab::Metrics::Dashboard::Processor).to receive(:new).and_return(double(process: file_content_hash))
end
- it_behaves_like 'valid dashboard cloning process', ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH, [STAGES::CommonMetricsInserter, STAGES::CustomMetricsInserter, STAGES::Sorter]
+ it_behaves_like 'valid dashboard cloning process', ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH, [::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter, ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter, ::Gitlab::Metrics::Dashboard::Stages::Sorter]
context 'selected branch already exists' do
let(:branch) { 'existing_branch' }
diff --git a/spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb b/spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb
index 034d6aba5d6..3547e1f0f8c 100644
--- a/spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/grafana_metric_embed_service_spec.rb
@@ -154,7 +154,7 @@ describe Metrics::Dashboard::GrafanaMetricEmbedService do
context 'when value not present in cache' do
it 'returns nil' do
- expect(ReactiveCachingWorker)
+ expect(ExternalServiceReactiveCachingWorker)
.to receive(:perform_async)
.with(service.class, service.id, *cache_params)
@@ -217,7 +217,7 @@ describe Metrics::Dashboard::DatasourceNameParser do
include GrafanaApiHelpers
let(:grafana_url) { valid_grafana_dashboard_link('https://gitlab.grafana.net') }
- let(:grafana_dashboard) { JSON.parse(fixture_file('grafana/dashboard_response.json'), symbolize_names: true) }
+ let(:grafana_dashboard) { Gitlab::Json.parse(fixture_file('grafana/dashboard_response.json'), symbolize_names: true) }
subject { described_class.new(grafana_url, grafana_dashboard).parse }
diff --git a/spec/services/metrics/dashboard/transient_embed_service_spec.rb b/spec/services/metrics/dashboard/transient_embed_service_spec.rb
index 4982f56cddc..125fff7c23c 100644
--- a/spec/services/metrics/dashboard/transient_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/transient_embed_service_spec.rb
@@ -67,6 +67,12 @@ describe Metrics::Dashboard::TransientEmbedService, :use_clean_rails_memory_stor
expect(get_type_for_embed(alt_embed)).to eq('area-chart')
end
+ context 'when embed_json cannot be parsed as json' do
+ let(:embed_json) { '' }
+
+ it_behaves_like 'misconfigured dashboard service response', :unprocessable_entity
+ end
+
private
def get_embed_json(type = 'line-graph')
diff --git a/spec/services/metrics/users_starred_dashboards/create_service_spec.rb b/spec/services/metrics/users_starred_dashboards/create_service_spec.rb
new file mode 100644
index 00000000000..eac4965ba44
--- /dev/null
+++ b/spec/services/metrics/users_starred_dashboards/create_service_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Metrics::UsersStarredDashboards::CreateService do
+ let_it_be(:user) { create(:user) }
+ let(:dashboard_path) { 'config/prometheus/common_metrics.yml' }
+ let(:service_instance) { described_class.new(user, project, dashboard_path) }
+ let(:project) { create(:project) }
+ let(:starred_dashboard_params) do
+ {
+ user: user,
+ project: project,
+ dashboard_path: dashboard_path
+ }
+ end
+
+ shared_examples 'prevented starred dashboard creation' do |message|
+ it 'returns error response', :aggregate_failures do
+ expect(Metrics::UsersStarredDashboard).not_to receive(:new)
+
+ response = service_instance.execute
+
+ expect(response.status).to be :error
+ expect(response.message).to eql message
+ end
+ end
+
+ describe '.execute' do
+ context 'with anonymous user' do
+ it_behaves_like 'prevented starred dashboard creation', 'You are not authorized to add star to this dashboard'
+ end
+
+ context 'with reporter user' do
+ before do
+ project.add_reporter(user)
+ end
+
+ context 'incorrect dashboard_path' do
+ let(:dashboard_path) { 'something_incorrect.yml' }
+
+ it_behaves_like 'prevented starred dashboard creation', 'Dashboard with requested path can not be found'
+ end
+
+ context 'with valid dashboard path' do
+ it 'creates starred dashboard and returns success response', :aggregate_failures do
+ expect_next_instance_of(Metrics::UsersStarredDashboard, starred_dashboard_params) do |starred_dashboard|
+ expect(starred_dashboard).to receive(:save).and_return true
+ end
+
+ response = service_instance.execute
+
+ expect(response.status).to be :success
+ end
+
+ context 'Metrics::UsersStarredDashboard has validation errors' do
+ it 'returns error response', :aggregate_failures do
+ expect_next_instance_of(Metrics::UsersStarredDashboard, starred_dashboard_params) do |starred_dashboard|
+ expect(starred_dashboard).to receive(:save).and_return(false)
+ expect(starred_dashboard).to receive(:errors).and_return(double(messages: { base: ['Model validation error'] }))
+ end
+
+ response = service_instance.execute
+
+ expect(response.status).to be :error
+ expect(response.message).to eql(base: ['Model validation error'])
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/metrics/users_starred_dashboards/delete_service_spec.rb b/spec/services/metrics/users_starred_dashboards/delete_service_spec.rb
new file mode 100644
index 00000000000..68a2fef5931
--- /dev/null
+++ b/spec/services/metrics/users_starred_dashboards/delete_service_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Metrics::UsersStarredDashboards::DeleteService do
+ subject(:service_instance) { described_class.new(user, project, dashboard_path) }
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+
+ describe '#execute' do
+ let_it_be(:user_starred_dashboard_1) { create(:metrics_users_starred_dashboard, user: user, project: project, dashboard_path: 'dashboard_1') }
+ let_it_be(:user_starred_dashboard_2) { create(:metrics_users_starred_dashboard, user: user, project: project) }
+ let_it_be(:other_user_starred_dashboard) { create(:metrics_users_starred_dashboard, project: project) }
+ let_it_be(:other_project_starred_dashboard) { create(:metrics_users_starred_dashboard, user: user) }
+
+ context 'without dashboard_path' do
+ let(:dashboard_path) { nil }
+
+ it 'does not scope user starred dashboards by dashboard path' do
+ result = service_instance.execute
+
+ expect(result.success?).to be_truthy
+ expect(result.payload[:deleted_rows]).to be(2)
+ expect(Metrics::UsersStarredDashboard.all).to contain_exactly(other_user_starred_dashboard, other_project_starred_dashboard)
+ end
+ end
+
+ context 'with dashboard_path' do
+ let(:dashboard_path) { 'dashboard_1' }
+
+ it 'does scope user starred dashboards by dashboard path' do
+ result = service_instance.execute
+
+ expect(result.success?).to be_truthy
+ expect(result.payload[:deleted_rows]).to be(1)
+ expect(Metrics::UsersStarredDashboard.all).to contain_exactly(user_starred_dashboard_2, other_user_starred_dashboard, other_project_starred_dashboard)
+ end
+ end
+ end
+end
diff --git a/spec/services/namespaces/check_storage_size_service_spec.rb b/spec/services/namespaces/check_storage_size_service_spec.rb
new file mode 100644
index 00000000000..50359ef90ab
--- /dev/null
+++ b/spec/services/namespaces/check_storage_size_service_spec.rb
@@ -0,0 +1,159 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Namespaces::CheckStorageSizeService, '#execute' do
+ let(:namespace) { build_stubbed(:namespace) }
+ let(:user) { build(:user, namespace: namespace) }
+ let(:service) { described_class.new(namespace, user) }
+ let(:current_size) { 150.megabytes }
+ let(:limit) { 100.megabytes }
+
+ subject(:response) { service.execute }
+
+ before do
+ allow(namespace).to receive(:root_ancestor).and_return(namespace)
+
+ root_storage_size = instance_double("RootStorageSize",
+ current_size: current_size,
+ limit: limit,
+ usage_ratio: limit == 0 ? 0 : current_size.to_f / limit.to_f,
+ above_size_limit?: current_size > limit
+ )
+
+ expect(Namespace::RootStorageSize).to receive(:new).and_return(root_storage_size)
+ end
+
+ context 'feature flag' do
+ it 'is successful when disabled' do
+ stub_feature_flags(namespace_storage_limit: false)
+
+ expect(response).to be_success
+ end
+
+ it 'errors when enabled' do
+ stub_feature_flags(namespace_storage_limit: true)
+
+ expect(response).to be_error
+ end
+
+ it 'is successful when feature flag is activated for another namespace' do
+ stub_feature_flags(namespace_storage_limit: build(:namespace))
+
+ expect(response).to be_success
+ end
+
+ it 'errors when feature flag is activated for the current namespace' do
+ stub_feature_flags(namespace_storage_limit: namespace )
+
+ expect(response).to be_error
+ expect(response.message).to be_present
+ end
+ end
+
+ context 'when limit is set to 0' do
+ let(:limit) { 0 }
+
+ it 'is successful and has no payload' do
+ expect(response).to be_success
+ expect(response.payload).to be_empty
+ end
+ end
+
+ context 'when current size is below threshold' do
+ let(:current_size) { 10.megabytes }
+
+ it 'is successful and has no payload' do
+ expect(response).to be_success
+ expect(response.payload).to be_empty
+ end
+ end
+
+ context 'when not admin of the namespace' do
+ let(:other_namespace) { build_stubbed(:namespace) }
+
+ subject(:response) { described_class.new(other_namespace, user).execute }
+
+ before do
+ allow(other_namespace).to receive(:root_ancestor).and_return(other_namespace)
+ end
+
+ it 'errors and has no payload' do
+ expect(response).to be_error
+ expect(response.payload).to be_empty
+ end
+ end
+
+ context 'when providing the child namespace' do
+ let(:namespace) { build_stubbed(:group) }
+ let(:child_namespace) { build_stubbed(:group, parent: namespace) }
+
+ subject(:response) { described_class.new(child_namespace, user).execute }
+
+ before do
+ allow(child_namespace).to receive(:root_ancestor).and_return(namespace)
+ namespace.add_owner(user)
+ end
+
+ it 'uses the root namespace' do
+ expect(response).to be_error
+ end
+ end
+
+ describe 'payload alert_level' do
+ subject { service.execute.payload[:alert_level] }
+
+ context 'when above info threshold' do
+ let(:current_size) { 50.megabytes }
+
+ it { is_expected.to eq(:info) }
+ end
+
+ context 'when above warning threshold' do
+ let(:current_size) { 75.megabytes }
+
+ it { is_expected.to eq(:warning) }
+ end
+
+ context 'when above alert threshold' do
+ let(:current_size) { 95.megabytes }
+
+ it { is_expected.to eq(:alert) }
+ end
+
+ context 'when above error threshold' do
+ let(:current_size) { 100.megabytes }
+
+ it { is_expected.to eq(:error) }
+ end
+ end
+
+ describe 'payload explanation_message' do
+ subject(:response) { service.execute.payload[:explanation_message] }
+
+ context 'when above limit' do
+ let(:current_size) { 110.megabytes }
+
+ it 'returns message with read-only warning' do
+ expect(response).to include("#{namespace.name} is now read-only")
+ end
+ end
+
+ context 'when below limit' do
+ let(:current_size) { 60.megabytes }
+
+ it { is_expected.to include('If you reach 100% storage capacity') }
+ end
+ end
+
+ describe 'payload usage_message' do
+ let(:current_size) { 60.megabytes }
+
+ subject(:response) { service.execute.payload[:usage_message] }
+
+ it 'returns current usage information' do
+ expect(response).to include("60 MB of 100 MB")
+ expect(response).to include("60%")
+ end
+ end
+end
diff --git a/spec/services/note_summary_spec.rb b/spec/services/note_summary_spec.rb
index aa4e41f4d8c..038e0cdb703 100644
--- a/spec/services/note_summary_spec.rb
+++ b/spec/services/note_summary_spec.rb
@@ -25,18 +25,18 @@ describe NoteSummary do
it 'returns note hash' do
Timecop.freeze do
expect(create_note_summary.note).to eq(noteable: noteable, project: project, author: user, note: 'note',
- created_at: Time.now)
+ created_at: Time.current)
end
end
context 'when noteable is a commit' do
- let(:noteable) { build(:commit, system_note_timestamp: Time.at(43)) }
+ let(:noteable) { build(:commit, system_note_timestamp: Time.zone.at(43)) }
it 'returns note hash specific to commit' do
expect(create_note_summary.note).to eq(
noteable: nil, project: project, author: user, note: 'note',
noteable_type: 'Commit', commit_id: noteable.id,
- created_at: Time.at(43)
+ created_at: Time.zone.at(43)
)
end
end
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index c461dd700ec..39d6fd26e31 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -342,6 +342,60 @@ describe Notes::CreateService do
end
end
+ context 'design note' do
+ subject(:service) { described_class.new(project, user, params) }
+
+ let_it_be(:design) { create(:design, :with_file) }
+ let_it_be(:project) { design.project }
+ let_it_be(:user) { project.owner }
+ let_it_be(:params) do
+ {
+ type: 'DiffNote',
+ noteable: design,
+ note: "A message",
+ position: {
+ old_path: design.full_path,
+ new_path: design.full_path,
+ position_type: 'image',
+ width: '100',
+ height: '100',
+ x: '50',
+ y: '50',
+ base_sha: design.diff_refs.base_sha,
+ start_sha: design.diff_refs.base_sha,
+ head_sha: design.diff_refs.head_sha
+ }
+ }
+ end
+
+ it 'can create diff notes for designs' do
+ note = service.execute
+
+ expect(note).to be_a(DiffNote)
+ expect(note).to be_persisted
+ expect(note.noteable).to eq(design)
+ end
+
+ it 'sends a notification about this note', :sidekiq_might_not_need_inline do
+ notifier = double
+ allow(::NotificationService).to receive(:new).and_return(notifier)
+
+ expect(notifier)
+ .to receive(:new_note)
+ .with have_attributes(noteable: design)
+
+ service.execute
+ end
+
+ it 'correctly builds the position of the note' do
+ note = service.execute
+
+ expect(note.position.new_path).to eq(design.full_path)
+ expect(note.position.old_path).to eq(design.full_path)
+ expect(note.position.diff_refs).to eq(design.diff_refs)
+ end
+ end
+
context 'note with emoji only' do
it 'creates regular note' do
opts = {
@@ -371,7 +425,7 @@ describe Notes::CreateService do
expect do
existing_note
- Timecop.freeze(Time.now + 1.minute) { subject }
+ Timecop.freeze(Time.current + 1.minute) { subject }
existing_note.reload
end.to change { existing_note.type }.from(nil).to('DiscussionNote')
diff --git a/spec/services/notes/post_process_service_spec.rb b/spec/services/notes/post_process_service_spec.rb
index 99db7897664..d564cacd2d8 100644
--- a/spec/services/notes/post_process_service_spec.rb
+++ b/spec/services/notes/post_process_service_spec.rb
@@ -43,5 +43,32 @@ describe Notes::PostProcessService do
described_class.new(@note).execute
end
end
+
+ context 'when the noteable is a design' do
+ let_it_be(:noteable) { create(:design, :with_file) }
+ let_it_be(:discussion_note) { create_note }
+
+ subject { described_class.new(note).execute }
+
+ def create_note(in_reply_to: nil)
+ create(:diff_note_on_design, noteable: noteable, in_reply_to: in_reply_to)
+ end
+
+ context 'when the note is the start of a new discussion' do
+ let(:note) { discussion_note }
+
+ it 'creates a new system note' do
+ expect { subject }.to change { Note.system.count }.by(1)
+ end
+ end
+
+ context 'when the note is a reply within a discussion' do
+ let_it_be(:note) { create_note(in_reply_to: discussion_note) }
+
+ it 'does not create a new system note' do
+ expect { subject }.not_to change { Note.system.count }
+ end
+ end
+ end
end
end
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 163ca0b9bc3..2a7166e3895 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -240,6 +240,17 @@ describe NotificationService, :mailer do
end
end
+ describe '#unknown_sign_in' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:ip) { '127.0.0.1' }
+
+ subject { notification.unknown_sign_in(user, ip) }
+
+ it 'sends email to the user' do
+ expect { subject }.to have_enqueued_email(user, ip, mail: 'unknown_sign_in_email')
+ end
+ end
+
describe 'Notes' do
context 'issue note' do
let(:project) { create(:project, :private) }
@@ -698,9 +709,60 @@ describe NotificationService, :mailer do
end
end
end
+
+ context 'when notified of a new design diff note' do
+ include DesignManagementTestHelpers
+
+ let_it_be(:design) { create(:design, :with_file) }
+ let_it_be(:project) { design.project }
+ let_it_be(:dev) { create(:user) }
+ let_it_be(:stranger) { create(:user) }
+ let_it_be(:note) do
+ create(:diff_note_on_design,
+ noteable: design,
+ note: "Hello #{dev.to_reference}, G'day #{stranger.to_reference}")
+ end
+ let(:mailer) { double(deliver_later: true) }
+
+ context 'design management is enabled' do
+ before do
+ enable_design_management
+ project.add_developer(dev)
+ allow(Notify).to receive(:note_design_email) { mailer }
+ end
+
+ it 'sends new note notifications' do
+ expect(subject).to receive(:send_new_note_notifications).with(note)
+
+ subject.new_note(note)
+ end
+
+ it 'sends a mail to the developer' do
+ expect(Notify)
+ .to receive(:note_design_email).with(dev.id, note.id, 'mentioned')
+
+ subject.new_note(note)
+ end
+
+ it 'does not notify non-developers' do
+ expect(Notify)
+ .not_to receive(:note_design_email).with(stranger.id, note.id)
+
+ subject.new_note(note)
+ end
+ end
+
+ context 'design management is disabled' do
+ it 'does not notify the user' do
+ expect(Notify).not_to receive(:note_design_email)
+
+ subject.new_note(note)
+ end
+ end
+ end
end
- describe '#send_new_release_notifications', :deliver_mails_inline, :sidekiq_inline do
+ describe '#send_new_release_notifications', :deliver_mails_inline do
context 'when recipients for a new release exist' do
let(:release) { create(:release) }
@@ -712,7 +774,7 @@ describe NotificationService, :mailer do
recipient_2 = NotificationRecipient.new(user_2, :custom, custom_action: :new_release)
allow(NotificationRecipients::BuildService).to receive(:build_new_release_recipients).and_return([recipient_1, recipient_2])
- release
+ notification.send_new_release_notifications(release)
should_email(user_1)
should_email(user_2)
diff --git a/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb b/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb
index 63fd0978c97..22fcc6b9a79 100644
--- a/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb
+++ b/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb
@@ -119,7 +119,7 @@ describe PagesDomains::ObtainLetsEncryptCertificateService do
cert = OpenSSL::X509::Certificate.new
cert.subject = cert.issuer = OpenSSL::X509::Name.parse(subject)
- cert.not_before = Time.now
+ cert.not_before = Time.current
cert.not_after = 1.year.from_now
cert.public_key = key.public_key
cert.serial = 0x0
diff --git a/spec/services/pod_logs/base_service_spec.rb b/spec/services/pod_logs/base_service_spec.rb
index 3ec5dc68c60..bc4989b59d9 100644
--- a/spec/services/pod_logs/base_service_spec.rb
+++ b/spec/services/pod_logs/base_service_spec.rb
@@ -103,6 +103,36 @@ describe ::PodLogs::BaseService do
expect(result[:container_name]).to eq(container_name)
end
end
+
+ context 'when pod_name is not a string' do
+ let(:params) do
+ {
+ 'pod_name' => { something_that_is: :not_a_string }
+ }
+ end
+
+ it 'returns error' do
+ result = subject.send(:check_arguments, {})
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('Invalid pod_name')
+ end
+ end
+
+ context 'when container_name is not a string' do
+ let(:params) do
+ {
+ 'container_name' => { something_that_is: :not_a_string }
+ }
+ end
+
+ it 'returns error' do
+ result = subject.send(:check_arguments, {})
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('Invalid container_name')
+ end
+ end
end
describe '#get_pod_names' do
diff --git a/spec/services/pod_logs/elasticsearch_service_spec.rb b/spec/services/pod_logs/elasticsearch_service_spec.rb
index e3efce1134b..8060d07461a 100644
--- a/spec/services/pod_logs/elasticsearch_service_spec.rb
+++ b/spec/services/pod_logs/elasticsearch_service_spec.rb
@@ -158,6 +158,21 @@ describe ::PodLogs::ElasticsearchService do
end
end
+ context 'with search provided and invalid' do
+ let(:params) do
+ {
+ 'search' => { term: "foo-bar" }
+ }
+ end
+
+ it 'returns error' do
+ result = subject.send(:check_search, {})
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq("Invalid search parameter")
+ end
+ end
+
context 'with search not provided' do
let(:params) do
{}
@@ -188,6 +203,21 @@ describe ::PodLogs::ElasticsearchService do
end
end
+ context 'with cursor provided and invalid' do
+ let(:params) do
+ {
+ 'cursor' => { term: "foo-bar" }
+ }
+ end
+
+ it 'returns error' do
+ result = subject.send(:check_cursor, {})
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq("Invalid cursor parameter")
+ end
+ end
+
context 'with cursor not provided' do
let(:params) do
{}
@@ -225,7 +255,7 @@ describe ::PodLogs::ElasticsearchService do
.and_return(Elasticsearch::Transport::Client.new)
allow_any_instance_of(::Gitlab::Elasticsearch::Logs::Lines)
.to receive(:pod_logs)
- .with(namespace, pod_name: pod_name, container_name: container_name, search: search, start_time: start_time, end_time: end_time, cursor: cursor)
+ .with(namespace, pod_name: pod_name, container_name: container_name, search: search, start_time: start_time, end_time: end_time, cursor: cursor, chart_above_v2: true)
.and_return({ logs: expected_logs, cursor: expected_cursor })
result = subject.send(:pod_logs, result_arg)
diff --git a/spec/services/pod_logs/kubernetes_service_spec.rb b/spec/services/pod_logs/kubernetes_service_spec.rb
index da89c7ee117..a1f7645323b 100644
--- a/spec/services/pod_logs/kubernetes_service_spec.rb
+++ b/spec/services/pod_logs/kubernetes_service_spec.rb
@@ -218,7 +218,7 @@ describe ::PodLogs::KubernetesService do
end
it 'returns error if pod_name was specified but does not exist' do
- result = subject.send(:check_pod_name, pod_name: 'another_pod', pods: [pod_name])
+ result = subject.send(:check_pod_name, pod_name: 'another-pod', pods: [pod_name])
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Pod does not exist')
@@ -230,6 +230,13 @@ describe ::PodLogs::KubernetesService do
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('pod_name cannot be larger than 253 chars')
end
+
+ it 'returns error if pod_name is in invalid format' do
+ result = subject.send(:check_pod_name, pod_name: "Invalid_pod_name", pods: [pod_name])
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('pod_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character')
+ end
end
describe '#check_container_name' do
@@ -287,5 +294,16 @@ describe ::PodLogs::KubernetesService do
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('container_name cannot be larger than 253 chars')
end
+
+ it 'returns error if container_name is in invalid format' do
+ result = subject.send(:check_container_name,
+ container_name: "Invalid_container_name",
+ pod_name: pod_name,
+ raw_pods: raw_pods
+ )
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('container_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character')
+ end
end
end
diff --git a/spec/services/post_receive_service_spec.rb b/spec/services/post_receive_service_spec.rb
index b4f48696b15..25f4122f134 100644
--- a/spec/services/post_receive_service_spec.rb
+++ b/spec/services/post_receive_service_spec.rb
@@ -166,6 +166,41 @@ describe PostReceiveService do
expect(subject).to include(build_alert_message(message))
end
end
+
+ context 'storage size limit alerts' do
+ let(:check_storage_size_response) { ServiceResponse.success }
+
+ before do
+ expect_next_instance_of(Namespaces::CheckStorageSizeService, project.namespace, user) do |check_storage_size_service|
+ expect(check_storage_size_service).to receive(:execute).and_return(check_storage_size_response)
+ end
+ end
+
+ context 'when there is no payload' do
+ it 'adds no alert' do
+ expect(subject.size).to eq(1)
+ end
+ end
+
+ context 'when there is payload' do
+ let(:check_storage_size_response) do
+ ServiceResponse.success(
+ payload: {
+ alert_level: :info,
+ usage_message: "Usage",
+ explanation_message: "Explanation"
+ }
+ )
+ end
+
+ it 'adds an alert' do
+ response = subject
+
+ expect(response.size).to eq(2)
+ expect(response).to include(build_alert_message("##### INFO #####\nUsage\nExplanation"))
+ end
+ end
+ end
end
context 'with PersonalSnippet' do
diff --git a/spec/services/projects/alerting/notify_service_spec.rb b/spec/services/projects/alerting/notify_service_spec.rb
index f08ecd397ec..b88f0ef5149 100644
--- a/spec/services/projects/alerting/notify_service_spec.rb
+++ b/spec/services/projects/alerting/notify_service_spec.rb
@@ -12,11 +12,16 @@ describe Projects::Alerting::NotifyService do
shared_examples 'processes incident issues' do |amount|
let(:create_incident_service) { spy }
+ let(:new_alert) { instance_double(AlertManagement::Alert, id: 503, persisted?: true) }
it 'processes issues' do
+ expect(AlertManagement::Alert)
+ .to receive(:create)
+ .and_return(new_alert)
+
expect(IncidentManagement::ProcessAlertWorker)
.to receive(:perform_async)
- .with(project.id, kind_of(Hash))
+ .with(project.id, kind_of(Hash), new_alert.id)
.exactly(amount).times
Sidekiq::Testing.inline! do
@@ -59,15 +64,26 @@ describe Projects::Alerting::NotifyService do
end
end
+ shared_examples 'NotifyService does not create alert' do
+ it 'does not create alert' do
+ expect { subject }.not_to change(AlertManagement::Alert, :count)
+ end
+ end
+
describe '#execute' do
let(:token) { 'invalid-token' }
- let(:starts_at) { Time.now.change(usec: 0) }
+ let(:starts_at) { Time.current.change(usec: 0) }
let(:service) { described_class.new(project, nil, payload) }
let(:payload_raw) do
{
- 'title' => 'alert title',
- 'start_time' => starts_at.rfc3339
- }
+ title: 'alert title',
+ start_time: starts_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']
+ }.with_indifferent_access
end
let(:payload) { ActionController::Parameters.new(payload_raw).permit! }
@@ -88,6 +104,73 @@ describe Projects::Alerting::NotifyService do
.and_return(incident_management_setting)
end
+ context 'with valid payload' do
+ let(:last_alert_attributes) do
+ AlertManagement::Alert.last.attributes
+ .except('id', 'iid', 'created_at', 'updated_at')
+ .with_indifferent_access
+ end
+
+ it 'creates AlertManagement::Alert' do
+ expect { subject }.to change(AlertManagement::Alert, :count).by(1)
+ end
+
+ it 'created alert has all data properly assigned' do
+ subject
+
+ expect(last_alert_attributes).to match(
+ project_id: project.id,
+ title: payload_raw.fetch(:title),
+ started_at: Time.zone.parse(payload_raw.fetch(:start_time)),
+ severity: payload_raw.fetch(:severity),
+ status: AlertManagement::Alert::STATUSES[:triggered],
+ events: 1,
+ hosts: payload_raw.fetch(:hosts),
+ payload: payload_raw.with_indifferent_access,
+ issue_id: nil,
+ description: payload_raw.fetch(:description),
+ monitoring_tool: payload_raw.fetch(:monitoring_tool),
+ service: payload_raw.fetch(:service),
+ fingerprint: nil,
+ ended_at: nil
+ )
+ end
+
+ context 'with a minimal payload' do
+ let(:payload_raw) do
+ {
+ title: 'alert title',
+ start_time: starts_at.rfc3339
+ }
+ end
+
+ it 'creates AlertManagement::Alert' do
+ expect { subject }.to change(AlertManagement::Alert, :count).by(1)
+ end
+
+ it 'created alert has all data properly assigned' do
+ subject
+
+ expect(last_alert_attributes).to match(
+ project_id: project.id,
+ title: payload_raw.fetch(:title),
+ started_at: Time.zone.parse(payload_raw.fetch(:start_time)),
+ severity: 'critical',
+ status: AlertManagement::Alert::STATUSES[:triggered],
+ events: 1,
+ hosts: [],
+ payload: payload_raw.with_indifferent_access,
+ issue_id: nil,
+ description: nil,
+ monitoring_tool: nil,
+ service: nil,
+ fingerprint: nil,
+ ended_at: nil
+ )
+ end
+ end
+ end
+
it_behaves_like 'does not process incident issues'
context 'issue enabled' do
@@ -103,6 +186,7 @@ describe Projects::Alerting::NotifyService do
end
it_behaves_like 'does not process incident issues due to error', http_status: :bad_request
+ it_behaves_like 'NotifyService does not create alert'
end
end
@@ -115,12 +199,14 @@ describe Projects::Alerting::NotifyService do
context 'with invalid token' do
it_behaves_like 'does not process incident issues due to error', http_status: :unauthorized
+ it_behaves_like 'NotifyService does not create alert'
end
context 'with deactivated Alerts Service' do
let!(:alerts_service) { create(:alerts_service, :inactive, project: project) }
it_behaves_like 'does not process incident issues due to error', http_status: :forbidden
+ it_behaves_like 'NotifyService does not create alert'
end
end
end
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index 1feea27eebc..e542f1e9108 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -489,6 +489,104 @@ describe Projects::CreateService, '#execute' do
end
end
+ it_behaves_like 'measurable service' do
+ before do
+ opts.merge!(
+ current_user: user,
+ path: 'foo'
+ )
+ end
+
+ let(:base_log_data) do
+ {
+ class: Projects::CreateService.name,
+ current_user: user.name,
+ project_full_path: "#{user.namespace.full_path}/#{opts[:path]}"
+ }
+ end
+
+ after do
+ create_project(user, opts)
+ end
+ end
+
+ context 'with specialized_project_authorization_workers' do
+ let_it_be(:other_user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
+ let(:opts) do
+ {
+ name: 'GitLab',
+ namespace_id: group.id
+ }
+ end
+
+ before do
+ group.add_maintainer(user)
+ group.add_developer(other_user)
+ end
+
+ it 'updates authorization for current_user' do
+ expect(Users::RefreshAuthorizedProjectsService).to(
+ receive(:new).with(user).and_call_original
+ )
+
+ project = create_project(user, opts)
+
+ expect(
+ Ability.allowed?(user, :read_project, project)
+ ).to be_truthy
+ end
+
+ it 'schedules authorization update for users with access to group' do
+ expect(AuthorizedProjectsWorker).not_to(
+ receive(:bulk_perform_async)
+ )
+ expect(AuthorizedProjectUpdate::ProjectCreateWorker).to(
+ receive(:perform_async).and_call_original
+ )
+ expect(AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker).to(
+ receive(:bulk_perform_in)
+ .with(1.hour, array_including([user.id], [other_user.id]))
+ .and_call_original
+ )
+
+ create_project(user, opts)
+ end
+
+ context 'when feature is disabled' do
+ before do
+ stub_feature_flags(specialized_project_authorization_workers: false)
+ end
+
+ it 'updates authorization for current_user' do
+ expect(Users::RefreshAuthorizedProjectsService).to(
+ receive(:new).with(user).and_call_original
+ )
+
+ project = create_project(user, opts)
+
+ expect(
+ Ability.allowed?(user, :read_project, project)
+ ).to be_truthy
+ end
+
+ it 'uses AuthorizedProjectsWorker' do
+ expect(AuthorizedProjectsWorker).to(
+ receive(:bulk_perform_async).with(array_including([user.id], [other_user.id])).and_call_original
+ )
+ expect(AuthorizedProjectUpdate::ProjectCreateWorker).not_to(
+ receive(:perform_async)
+ )
+ expect(AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker).not_to(
+ receive(:bulk_perform_in)
+ )
+
+ create_project(user, opts)
+ end
+ end
+ end
+
def create_project(user, opts)
Projects::CreateService.new(user, opts).execute
end
diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb
index c8354f6ba4e..112a41c773b 100644
--- a/spec/services/projects/fork_service_spec.rb
+++ b/spec/services/projects/fork_service_spec.rb
@@ -320,7 +320,13 @@ describe Projects::ForkService do
allow_any_instance_of(Gitlab::Git::Repository).to receive(:checksum)
.and_return(::Gitlab::Git::BLANK_SHA)
- Projects::UpdateRepositoryStorageService.new(project).execute('test_second_storage')
+ storage_move = create(
+ :project_repository_storage_move,
+ :scheduled,
+ project: project,
+ destination_storage_name: 'test_second_storage'
+ )
+ Projects::UpdateRepositoryStorageService.new(storage_move).execute
fork_after_move = fork_project(project)
pool_repository_before_move = PoolRepository.joins(:shard)
.find_by(source_project: project, shards: { name: 'default' })
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 34c37be6703..070dd5fc1b8 100644
--- a/spec/services/projects/hashed_storage/base_attachment_service_spec.rb
+++ b/spec/services/projects/hashed_storage/base_attachment_service_spec.rb
@@ -31,7 +31,7 @@ describe Projects::HashedStorage::BaseAttachmentService do
expect(Dir.exist?(target_path)).to be_truthy
Timecop.freeze do
- suffix = Time.now.utc.to_i
+ suffix = Time.current.utc.to_i
subject.send(:discard_path!, target_path)
expected_renamed_path = "#{target_path}-#{suffix}"
diff --git a/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb b/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
index 71be335c11d..f1eaf8324e0 100644
--- a/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
+++ b/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
@@ -6,7 +6,7 @@ describe Projects::HashedStorage::MigrateRepositoryService do
include GitHelpers
let(:gitlab_shell) { Gitlab::Shell.new }
- let(:project) { create(:project, :legacy_storage, :repository, :wiki_repo) }
+ let(:project) { create(:project, :legacy_storage, :repository, :wiki_repo, :design_repo) }
let(:legacy_storage) { Storage::LegacyProject.new(project) }
let(:hashed_storage) { Storage::Hashed.new(project) }
@@ -45,11 +45,12 @@ describe Projects::HashedStorage::MigrateRepositoryService do
end
context 'when succeeds' do
- it 'renames project and wiki repositories' do
+ it 'renames project, wiki and design repositories' do
service.execute
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.git")).to be_truthy
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.wiki.git")).to be_truthy
+ expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.design.git")).to be_truthy
end
it 'updates project to be hashed and not read-only' do
@@ -59,9 +60,10 @@ describe Projects::HashedStorage::MigrateRepositoryService do
expect(project.repository_read_only).to be_falsey
end
- it 'move operation is called for both repositories' do
+ it 'move operation is called for all repositories' do
expect_move_repository(old_disk_path, new_disk_path)
expect_move_repository("#{old_disk_path}.wiki", "#{new_disk_path}.wiki")
+ expect_move_repository("#{old_disk_path}.design", "#{new_disk_path}.design")
service.execute
end
@@ -86,6 +88,7 @@ describe Projects::HashedStorage::MigrateRepositoryService do
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.git")).to be_falsey
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.wiki.git")).to be_falsey
+ expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.design.git")).to be_falsey
expect(project.repository_read_only?).to be_falsey
end
@@ -97,6 +100,7 @@ describe Projects::HashedStorage::MigrateRepositoryService do
it 'does not try to move nil repository over existing' do
expect(gitlab_shell).not_to receive(:mv_repository).with(project.repository_storage, old_disk_path, new_disk_path)
expect_move_repository("#{old_disk_path}.wiki", "#{new_disk_path}.wiki")
+ expect_move_repository("#{old_disk_path}.design", "#{new_disk_path}.design")
service.execute
end
diff --git a/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb b/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb
index 6dcd2ff4555..1c0f446d9cf 100644
--- a/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb
+++ b/spec/services/projects/hashed_storage/rollback_repository_service_spec.rb
@@ -6,7 +6,7 @@ describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab_redis
include GitHelpers
let(:gitlab_shell) { Gitlab::Shell.new }
- let(:project) { create(:project, :repository, :wiki_repo, storage_version: ::Project::HASHED_STORAGE_FEATURES[:repository]) }
+ let(:project) { create(:project, :repository, :wiki_repo, :design_repo, storage_version: ::Project::HASHED_STORAGE_FEATURES[:repository]) }
let(:legacy_storage) { Storage::LegacyProject.new(project) }
let(:hashed_storage) { Storage::Hashed.new(project) }
@@ -45,11 +45,12 @@ describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab_redis
end
context 'when succeeds' do
- it 'renames project and wiki repositories' do
+ it 'renames project, wiki and design repositories' do
service.execute
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.git")).to be_truthy
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.wiki.git")).to be_truthy
+ expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.design.git")).to be_truthy
end
it 'updates project to be legacy and not read-only' do
@@ -62,6 +63,7 @@ describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab_redis
it 'move operation is called for both repositories' do
expect_move_repository(old_disk_path, new_disk_path)
expect_move_repository("#{old_disk_path}.wiki", "#{new_disk_path}.wiki")
+ expect_move_repository("#{old_disk_path}.design", "#{new_disk_path}.design")
service.execute
end
@@ -86,6 +88,7 @@ describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab_redis
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.git")).to be_falsey
expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.wiki.git")).to be_falsey
+ expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_disk_path}.design.git")).to be_falsey
expect(project.repository_read_only?).to be_falsey
end
@@ -97,6 +100,7 @@ describe Projects::HashedStorage::RollbackRepositoryService, :clean_gitlab_redis
it 'does not try to move nil repository over existing' do
expect(gitlab_shell).not_to receive(:mv_repository).with(project.repository_storage, old_disk_path, new_disk_path)
expect_move_repository("#{old_disk_path}.wiki", "#{new_disk_path}.wiki")
+ expect_move_repository("#{old_disk_path}.design", "#{new_disk_path}.design")
service.execute
end
diff --git a/spec/services/projects/import_export/export_service_spec.rb b/spec/services/projects/import_export/export_service_spec.rb
index e00507d1827..5f496cb1e56 100644
--- a/spec/services/projects/import_export/export_service_spec.rb
+++ b/spec/services/projects/import_export/export_service_spec.rb
@@ -7,9 +7,10 @@ describe Projects::ImportExport::ExportService do
let!(:user) { create(:user) }
let(:project) { create(:project) }
let(:shared) { project.import_export_shared }
- let(:service) { described_class.new(project, user) }
let!(:after_export_strategy) { Gitlab::ImportExport::AfterExportStrategies::DownloadNotificationStrategy.new }
+ subject(:service) { described_class.new(project, user) }
+
before do
project.add_maintainer(user)
end
@@ -46,8 +47,8 @@ describe Projects::ImportExport::ExportService do
# in the corresponding EE spec.
skip if Gitlab.ee?
- # once for the normal repo, once for the wiki
- expect(Gitlab::ImportExport::RepoSaver).to receive(:new).twice.and_call_original
+ # once for the normal repo, once for the wiki repo, and once for the design repo
+ expect(Gitlab::ImportExport::RepoSaver).to receive(:new).exactly(3).times.and_call_original
service.execute
end
@@ -58,6 +59,12 @@ describe Projects::ImportExport::ExportService do
service.execute
end
+ it 'saves the design repo' do
+ expect(Gitlab::ImportExport::DesignRepoSaver).to receive(:new).and_call_original
+
+ service.execute
+ end
+
it 'saves the lfs objects' do
expect(Gitlab::ImportExport::LfsSaver).to receive(:new).and_call_original
@@ -177,5 +184,20 @@ describe Projects::ImportExport::ExportService do
expect { service.execute }.to raise_error(Gitlab::ImportExport::Error).with_message(expected_message)
end
end
+
+ it_behaves_like 'measurable service' do
+ let(:base_log_data) do
+ {
+ class: described_class.name,
+ current_user: user.name,
+ project_full_path: project.full_path,
+ file_path: shared.export_path
+ }
+ end
+
+ after do
+ service.execute(after_export_strategy)
+ end
+ end
end
end
diff --git a/spec/services/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb
index af8118f9b11..ca6750b373d 100644
--- a/spec/services/projects/import_service_spec.rb
+++ b/spec/services/projects/import_service_spec.rb
@@ -264,13 +264,33 @@ describe Projects::ImportService do
it 'fails with port 25' do
project.import_url = "https://github.com:25/vim/vim.git"
- result = described_class.new(project, user).execute
+ result = subject.execute
expect(result[:status]).to eq :error
expect(result[:message]).to include('Only allowed ports are 80, 443')
end
end
+ it_behaves_like 'measurable service' do
+ let(:base_log_data) do
+ {
+ class: described_class.name,
+ current_user: user.name,
+ project_full_path: project.full_path,
+ import_type: project.import_type,
+ file_path: project.import_source
+ }
+ end
+
+ before do
+ project.import_type = 'github'
+ end
+
+ after do
+ subject.execute
+ end
+ end
+
def stub_github_omniauth_provider
provider = OpenStruct.new(
'name' => 'github',
diff --git a/spec/services/projects/prometheus/alerts/create_events_service_spec.rb b/spec/services/projects/prometheus/alerts/create_events_service_spec.rb
index 1d726db6ce3..35f23afd7a2 100644
--- a/spec/services/projects/prometheus/alerts/create_events_service_spec.rb
+++ b/spec/services/projects/prometheus/alerts/create_events_service_spec.rb
@@ -50,7 +50,7 @@ describe Projects::Prometheus::Alerts::CreateEventsService do
let(:events) { service.execute }
context 'with a firing payload' do
- let(:started_at) { truncate_to_second(Time.now) }
+ let(:started_at) { truncate_to_second(Time.current) }
let(:firing_event) { alert_payload(status: 'firing', started_at: started_at) }
let(:alerts_payload) { { 'alerts' => [firing_event] } }
@@ -87,7 +87,7 @@ describe Projects::Prometheus::Alerts::CreateEventsService do
end
context 'with a resolved payload' do
- let(:started_at) { truncate_to_second(Time.now) }
+ let(:started_at) { truncate_to_second(Time.current) }
let(:ended_at) { started_at + 1 }
let(:payload_key) { PrometheusAlertEvent.payload_key_for(alert.prometheus_metric_id, utc_rfc3339(started_at)) }
let(:resolved_event) { alert_payload(status: 'resolved', started_at: started_at, ended_at: ended_at) }
@@ -285,7 +285,7 @@ describe Projects::Prometheus::Alerts::CreateEventsService do
private
- def alert_payload(status: 'firing', started_at: Time.now, ended_at: Time.now, gitlab_alert_id: alert.prometheus_metric_id, title: nil, environment: nil)
+ def alert_payload(status: 'firing', started_at: Time.current, ended_at: Time.current, gitlab_alert_id: alert.prometheus_metric_id, title: nil, environment: nil)
payload = {}
payload['status'] = status if status
diff --git a/spec/services/projects/prometheus/alerts/notify_service_spec.rb b/spec/services/projects/prometheus/alerts/notify_service_spec.rb
index dce96dda1e3..009543f9016 100644
--- a/spec/services/projects/prometheus/alerts/notify_service_spec.rb
+++ b/spec/services/projects/prometheus/alerts/notify_service_spec.rb
@@ -217,6 +217,32 @@ describe Projects::Prometheus::Alerts::NotifyService do
end
end
+ context 'process Alert Management alerts' do
+ let(:process_service) { instance_double(AlertManagement::ProcessPrometheusAlertService) }
+
+ before do
+ create(:prometheus_service, project: project)
+ create(:project_alerting_setting, project: project, token: token)
+ end
+
+ context 'with multiple firing alerts and resolving alerts' do
+ let(:payload_raw) do
+ payload_for(firing: [alert_firing, alert_firing], resolved: [alert_resolved])
+ end
+
+ it 'processes Prometheus alerts' do
+ expect(AlertManagement::ProcessPrometheusAlertService)
+ .to receive(:new)
+ .with(project, nil, kind_of(Hash))
+ .exactly(3).times
+ .and_return(process_service)
+ expect(process_service).to receive(:execute).exactly(3).times
+
+ subject
+ end
+ end
+ end
+
context 'process incident issues' do
before do
create(:prometheus_service, project: project)
@@ -286,6 +312,13 @@ describe Projects::Prometheus::Alerts::NotifyService do
it_behaves_like 'no notifications', http_status: :bad_request
+ it 'does not process Prometheus alerts' do
+ expect(AlertManagement::ProcessPrometheusAlertService)
+ .not_to receive(:new)
+
+ subject
+ end
+
it 'does not process issues' do
expect(IncidentManagement::ProcessPrometheusAlertWorker)
.not_to receive(:perform_async)
diff --git a/spec/services/projects/propagate_service_template_spec.rb b/spec/services/projects/propagate_service_template_spec.rb
index 2c3effec617..7188ac5f733 100644
--- a/spec/services/projects/propagate_service_template_spec.rb
+++ b/spec/services/projects/propagate_service_template_spec.rb
@@ -8,16 +8,19 @@ describe Projects::PropagateServiceTemplate do
PushoverService.create(
template: true,
active: true,
+ push_events: false,
properties: {
device: 'MyDevice',
sound: 'mic',
priority: 4,
user_key: 'asdf',
api_key: '123456789'
- })
+ }
+ )
end
let!(:project) { create(:project) }
+ let(:excluded_attributes) { %w[id project_id template created_at updated_at title description] }
it 'creates services for projects' do
expect(project.pushover_service).to be_nil
@@ -35,7 +38,7 @@ describe Projects::PropagateServiceTemplate do
properties: {
bamboo_url: 'http://gitlab.com',
username: 'mic',
- password: "password",
+ password: 'password',
build_key: 'build'
}
)
@@ -54,7 +57,7 @@ describe Projects::PropagateServiceTemplate do
properties: {
bamboo_url: 'http://gitlab.com',
username: 'mic',
- password: "password",
+ password: 'password',
build_key: 'build'
}
)
@@ -70,6 +73,33 @@ describe Projects::PropagateServiceTemplate do
described_class.propagate(service_template)
expect(project.pushover_service.properties).to eq(service_template.properties)
+
+ expect(project.pushover_service.attributes.except(*excluded_attributes))
+ .to eq(service_template.attributes.except(*excluded_attributes))
+ end
+
+ context 'service with data fields' do
+ let(:service_template) do
+ JiraService.create!(
+ template: true,
+ active: true,
+ push_events: false,
+ url: 'http://jira.instance.com',
+ username: 'user',
+ password: 'secret'
+ )
+ end
+
+ it 'creates the service containing the template attributes' do
+ described_class.propagate(service_template)
+
+ expect(project.jira_service.attributes.except(*excluded_attributes))
+ .to eq(service_template.attributes.except(*excluded_attributes))
+
+ excluded_attributes = %w[id service_id created_at updated_at]
+ expect(project.jira_service.data_fields.attributes.except(*excluded_attributes))
+ .to eq(service_template.data_fields.attributes.except(*excluded_attributes))
+ end
end
describe 'bulk update', :use_sql_query_cache do
diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb
index f17ddb22d22..0e2431c0e44 100644
--- a/spec/services/projects/transfer_service_spec.rb
+++ b/spec/services/projects/transfer_service_spec.rb
@@ -9,18 +9,26 @@ describe Projects::TransferService do
let(:group) { create(:group) }
let(:project) { create(:project, :repository, :legacy_storage, namespace: user.namespace) }
+ subject(:execute_transfer) { described_class.new(project, user).execute(group) }
+
context 'namespace -> namespace' do
before do
- allow_any_instance_of(Gitlab::UploadsTransfer)
- .to receive(:move_project).and_return(true)
- allow_any_instance_of(Gitlab::PagesTransfer)
- .to receive(:move_project).and_return(true)
+ allow_next_instance_of(Gitlab::UploadsTransfer) do |service|
+ allow(service).to receive(:move_project).and_return(true)
+ end
+ allow_next_instance_of(Gitlab::PagesTransfer) do |service|
+ allow(service).to receive(:move_project).and_return(true)
+ end
+
group.add_owner(user)
- @result = transfer_project(project, user, group)
end
- it { expect(@result).to be_truthy }
- it { expect(project.namespace).to eq(group) }
+ it 'updates the namespace' do
+ transfer_result = execute_transfer
+
+ expect(transfer_result).to be_truthy
+ expect(project.namespace).to eq(group)
+ end
end
context 'when transfer succeeds' do
@@ -31,26 +39,29 @@ describe Projects::TransferService do
it 'sends notifications' do
expect_any_instance_of(NotificationService).to receive(:project_was_moved)
- transfer_project(project, user, group)
+ execute_transfer
end
it 'invalidates the user\'s personal_project_count cache' do
expect(user).to receive(:invalidate_personal_projects_count)
- transfer_project(project, user, group)
+ execute_transfer
end
it 'executes system hooks' do
- transfer_project(project, user, group) do |service|
+ expect_next_instance_of(described_class) do |service|
expect(service).to receive(:execute_system_hooks)
end
+
+ execute_transfer
end
it 'moves the disk path', :aggregate_failures do
old_path = project.repository.disk_path
old_full_path = project.repository.full_path
- transfer_project(project, user, group)
+ execute_transfer
+
project.reload_repository!
expect(project.repository.disk_path).not_to eq(old_path)
@@ -60,13 +71,13 @@ describe Projects::TransferService do
end
it 'updates project full path in .git/config' do
- transfer_project(project, user, group)
+ execute_transfer
expect(rugged_config['gitlab.fullpath']).to eq "#{group.full_path}/#{project.path}"
end
it 'updates storage location' do
- transfer_project(project, user, group)
+ execute_transfer
expect(project.project_repository).to have_attributes(
disk_path: "#{group.full_path}/#{project.path}",
@@ -80,7 +91,7 @@ describe Projects::TransferService do
def attempt_project_transfer(&block)
expect do
- transfer_project(project, user, group, &block)
+ execute_transfer
end.to raise_error(ActiveRecord::ActiveRecordError)
end
@@ -138,13 +149,15 @@ describe Projects::TransferService do
end
context 'namespace -> no namespace' do
- before do
- @result = transfer_project(project, user, nil)
- end
+ let(:group) { nil }
+
+ it 'does not allow the project transfer' do
+ transfer_result = execute_transfer
- it { expect(@result).to eq false }
- it { expect(project.namespace).to eq(user.namespace) }
- it { expect(project.errors.messages[:new_namespace].first).to eq 'Please select a new namespace for your project.' }
+ expect(transfer_result).to eq false
+ expect(project.namespace).to eq(user.namespace)
+ expect(project.errors.messages[:new_namespace].first).to eq 'Please select a new namespace for your project.'
+ end
end
context 'disallow transferring of project with tags' do
@@ -156,18 +169,18 @@ describe Projects::TransferService do
project.container_repositories << container_repository
end
- subject { transfer_project(project, user, group) }
-
- it { is_expected.to be_falsey }
+ it 'does not allow the project transfer' do
+ expect(execute_transfer).to eq false
+ end
end
context 'namespace -> not allowed namespace' do
- before do
- @result = transfer_project(project, user, group)
- end
+ it 'does not allow the project transfer' do
+ transfer_result = execute_transfer
- it { expect(@result).to eq false }
- it { expect(project.namespace).to eq(user.namespace) }
+ expect(transfer_result).to eq false
+ expect(project.namespace).to eq(user.namespace)
+ end
end
context 'namespace which contains orphan repository with same projects path name' do
@@ -177,99 +190,94 @@ describe Projects::TransferService do
group.add_owner(user)
TestEnv.create_bare_repository(fake_repo_path)
-
- @result = transfer_project(project, user, group)
end
after do
FileUtils.rm_rf(fake_repo_path)
end
- it { expect(@result).to eq false }
- it { expect(project.namespace).to eq(user.namespace) }
- it { expect(project.errors[:new_namespace]).to include('Cannot move project') }
+ it 'does not allow the project transfer' do
+ transfer_result = execute_transfer
+
+ expect(transfer_result).to eq false
+ expect(project.namespace).to eq(user.namespace)
+ expect(project.errors[:new_namespace]).to include('Cannot move project')
+ end
end
context 'target namespace containing the same project name' do
before do
group.add_owner(user)
- project.update(name: 'new_name')
+ create(:project, name: project.name, group: group, path: 'other')
+ end
- create(:project, name: 'new_name', group: group, path: 'other')
+ it 'does not allow the project transfer' do
+ transfer_result = execute_transfer
- @result = transfer_project(project, user, group)
+ expect(transfer_result).to eq false
+ expect(project.namespace).to eq(user.namespace)
+ expect(project.errors[:new_namespace]).to include('Project with same name or path in target namespace already exists')
end
-
- it { expect(@result).to eq false }
- it { expect(project.namespace).to eq(user.namespace) }
- it { expect(project.errors[:new_namespace]).to include('Project with same name or path in target namespace already exists') }
end
context 'target namespace containing the same project path' do
before do
group.add_owner(user)
-
create(:project, name: 'other-name', path: project.path, group: group)
-
- @result = transfer_project(project, user, group)
end
- it { expect(@result).to eq false }
- it { expect(project.namespace).to eq(user.namespace) }
- it { expect(project.errors[:new_namespace]).to include('Project with same name or path in target namespace already exists') }
+ it 'does not allow the project transfer' do
+ transfer_result = execute_transfer
+
+ expect(transfer_result).to eq false
+ expect(project.namespace).to eq(user.namespace)
+ expect(project.errors[:new_namespace]).to include('Project with same name or path in target namespace already exists')
+ end
end
context 'target namespace allows developers to create projects' do
let(:group) { create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS) }
context 'the user is a member of the target namespace with developer permissions' do
- subject(:transfer_project_result) { transfer_project(project, user, group) }
-
before do
group.add_developer(user)
end
it 'does not allow project transfer to the target namespace' do
- expect(transfer_project_result).to eq false
+ transfer_result = execute_transfer
+
+ expect(transfer_result).to eq false
expect(project.namespace).to eq(user.namespace)
expect(project.errors[:new_namespace]).to include('Transfer failed, please contact an admin.')
end
end
end
- def transfer_project(project, user, new_namespace)
- service = Projects::TransferService.new(project, user)
-
- yield(service) if block_given?
-
- service.execute(new_namespace)
- end
-
context 'visibility level' do
- let(:internal_group) { create(:group, :internal) }
+ let(:group) { create(:group, :internal) }
before do
- internal_group.add_owner(user)
+ group.add_owner(user)
end
context 'when namespace visibility level < project visibility level' do
- let(:public_project) { create(:project, :public, :repository, namespace: user.namespace) }
+ let(:project) { create(:project, :public, :repository, namespace: user.namespace) }
before do
- transfer_project(public_project, user, internal_group)
+ execute_transfer
end
- it { expect(public_project.visibility_level).to eq(internal_group.visibility_level) }
+ it { expect(project.visibility_level).to eq(group.visibility_level) }
end
context 'when namespace visibility level > project visibility level' do
- let(:private_project) { create(:project, :private, :repository, namespace: user.namespace) }
+ let(:project) { create(:project, :private, :repository, namespace: user.namespace) }
before do
- transfer_project(private_project, user, internal_group)
+ execute_transfer
end
- it { expect(private_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) }
+ it { expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) }
end
end
@@ -277,9 +285,11 @@ describe Projects::TransferService do
it 'delegates transfer to Labels::TransferService' do
group.add_owner(user)
- expect_any_instance_of(Labels::TransferService).to receive(:execute).once.and_call_original
+ expect_next_instance_of(Labels::TransferService, user, project.group, project) do |labels_transfer_service|
+ expect(labels_transfer_service).to receive(:execute).once.and_call_original
+ end
- transfer_project(project, user, group)
+ execute_transfer
end
end
@@ -287,49 +297,52 @@ describe Projects::TransferService do
it 'delegates transfer to Milestones::TransferService' do
group.add_owner(user)
- expect(Milestones::TransferService).to receive(:new).with(user, project.group, project).and_call_original
- expect_any_instance_of(Milestones::TransferService).to receive(:execute).once
+ expect_next_instance_of(Milestones::TransferService, user, project.group, project) do |milestones_transfer_service|
+ expect(milestones_transfer_service).to receive(:execute).once.and_call_original
+ end
- transfer_project(project, user, group)
+ execute_transfer
end
end
context 'when hashed storage in use' do
- let!(:hashed_project) { create(:project, :repository, namespace: user.namespace) }
- let!(:old_disk_path) { hashed_project.repository.disk_path }
+ let!(:project) { create(:project, :repository, namespace: user.namespace) }
+ let!(:old_disk_path) { project.repository.disk_path }
before do
group.add_owner(user)
end
it 'does not move the disk path', :aggregate_failures do
- new_full_path = "#{group.full_path}/#{hashed_project.path}"
+ new_full_path = "#{group.full_path}/#{project.path}"
- transfer_project(hashed_project, user, group)
- hashed_project.reload_repository!
+ execute_transfer
- expect(hashed_project.repository).to have_attributes(
+ project.reload_repository!
+
+ expect(project.repository).to have_attributes(
disk_path: old_disk_path,
full_path: new_full_path
)
- expect(hashed_project.disk_path).to eq(old_disk_path)
+ expect(project.disk_path).to eq(old_disk_path)
end
it 'does not move the disk path when the transfer fails', :aggregate_failures do
- old_full_path = hashed_project.full_path
+ old_full_path = project.full_path
expect_next_instance_of(described_class) do |service|
allow(service).to receive(:execute_system_hooks).and_raise('foo')
end
- expect { transfer_project(hashed_project, user, group) }.to raise_error('foo')
- hashed_project.reload_repository!
+ expect { execute_transfer }.to raise_error('foo')
+
+ project.reload_repository!
- expect(hashed_project.repository).to have_attributes(
+ expect(project.repository).to have_attributes(
disk_path: old_disk_path,
full_path: old_full_path
)
- expect(hashed_project.disk_path).to eq(old_disk_path)
+ expect(project.disk_path).to eq(old_disk_path)
end
end
@@ -344,18 +357,102 @@ describe Projects::TransferService do
end
it 'refreshes the permissions of the old and new namespace' do
- transfer_project(project, owner, group)
+ execute_transfer
expect(group_member.authorized_projects).to include(project)
expect(owner.authorized_projects).to include(project)
end
it 'only schedules a single job for every user' do
- expect(UserProjectAccessChangedService).to receive(:new)
- .with([owner.id, group_member.id])
- .and_call_original
+ expect_next_instance_of(UserProjectAccessChangedService, [owner.id, group_member.id]) do |service|
+ expect(service).to receive(:execute).once.and_call_original
+ end
+
+ execute_transfer
+ end
+ end
+
+ describe 'transferring a design repository' do
+ subject { described_class.new(project, user) }
+
+ before do
+ group.add_owner(user)
+ end
+
+ def design_repository
+ project.design_repository
+ end
+
+ it 'does not create a design repository' do
+ expect(subject.execute(group)).to be true
+
+ project.clear_memoization(:design_repository)
+
+ expect(design_repository.exists?).to be false
+ end
- transfer_project(project, owner, group)
+ describe 'when the project has a design repository' do
+ let(:project_repo_path) { "#{project.path}#{::Gitlab::GlRepository::DESIGN.path_suffix}" }
+ let(:old_full_path) { "#{user.namespace.full_path}/#{project_repo_path}" }
+ let(:new_full_path) { "#{group.full_path}/#{project_repo_path}" }
+
+ context 'with legacy storage' do
+ let(:project) { create(:project, :repository, :legacy_storage, :design_repo, namespace: user.namespace) }
+
+ it 'moves the repository' do
+ expect(subject.execute(group)).to be true
+
+ project.clear_memoization(:design_repository)
+
+ expect(design_repository).to have_attributes(
+ disk_path: new_full_path,
+ full_path: new_full_path
+ )
+ end
+
+ it 'does not move the repository when an error occurs', :aggregate_failures do
+ allow(subject).to receive(:execute_system_hooks).and_raise('foo')
+ expect { subject.execute(group) }.to raise_error('foo')
+
+ project.clear_memoization(:design_repository)
+
+ expect(design_repository).to have_attributes(
+ disk_path: old_full_path,
+ full_path: old_full_path
+ )
+ end
+ end
+
+ context 'with hashed storage' do
+ let(:project) { create(:project, :repository, namespace: user.namespace) }
+
+ it 'does not move the repository' do
+ old_disk_path = design_repository.disk_path
+
+ expect(subject.execute(group)).to be true
+
+ project.clear_memoization(:design_repository)
+
+ expect(design_repository).to have_attributes(
+ disk_path: old_disk_path,
+ full_path: new_full_path
+ )
+ end
+
+ it 'does not move the repository when an error occurs' do
+ old_disk_path = design_repository.disk_path
+
+ allow(subject).to receive(:execute_system_hooks).and_raise('foo')
+ expect { subject.execute(group) }.to raise_error('foo')
+
+ project.clear_memoization(:design_repository)
+
+ expect(design_repository).to have_attributes(
+ disk_path: old_disk_path,
+ full_path: old_full_path
+ )
+ end
+ end
end
end
diff --git a/spec/services/projects/update_remote_mirror_service_spec.rb b/spec/services/projects/update_remote_mirror_service_spec.rb
index 4396ccab584..38c2dc0780e 100644
--- a/spec/services/projects/update_remote_mirror_service_spec.rb
+++ b/spec/services/projects/update_remote_mirror_service_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
describe Projects::UpdateRemoteMirrorService do
let(:project) { create(:project, :repository) }
let(:remote_project) { create(:forked_project_with_submodules) }
- let(:remote_mirror) { project.remote_mirrors.create!(url: remote_project.http_url_to_repo, enabled: true, only_protected_branches: false) }
+ let(:remote_mirror) { create(:remote_mirror, project: project, enabled: true) }
let(:remote_name) { remote_mirror.remote_name }
subject(:service) { described_class.new(project, project.creator) }
@@ -16,7 +16,9 @@ describe Projects::UpdateRemoteMirrorService do
before do
project.repository.add_branch(project.owner, 'existing-branch', 'master')
- allow(remote_mirror).to receive(:update_repository).and_return(true)
+ allow(remote_mirror)
+ .to receive(:update_repository)
+ .and_return(double(divergent_refs: []))
end
it 'ensures the remote exists' do
@@ -53,7 +55,7 @@ describe Projects::UpdateRemoteMirrorService do
it 'marks the mirror as failed and raises the error when an unexpected error occurs' do
allow(project.repository).to receive(:fetch_remote).and_raise('Badly broken')
- expect { execute! }.to raise_error /Badly broken/
+ expect { execute! }.to raise_error(/Badly broken/)
expect(remote_mirror).to be_failed
expect(remote_mirror.last_error).to include('Badly broken')
@@ -83,32 +85,21 @@ describe Projects::UpdateRemoteMirrorService do
end
end
- context 'when syncing all branches' do
- it 'push all the branches the first time' do
+ context 'when there are divergent refs' do
+ before do
stub_fetch_remote(project, remote_name: remote_name, ssh_auth: remote_mirror)
-
- expect(remote_mirror).to receive(:update_repository).with({})
-
- execute!
end
- end
- context 'when only syncing protected branches' do
- it 'sync updated protected branches' do
- stub_fetch_remote(project, remote_name: remote_name, ssh_auth: remote_mirror)
- protected_branch = create_protected_branch(project)
- remote_mirror.only_protected_branches = true
-
- expect(remote_mirror)
- .to receive(:update_repository)
- .with(only_branches_matching: [protected_branch.name])
+ it 'marks the mirror as failed and sets an error message' do
+ response = double(divergent_refs: %w[refs/heads/master refs/heads/develop])
+ expect(remote_mirror).to receive(:update_repository).and_return(response)
execute!
- end
- def create_protected_branch(project)
- branch_name = project.repository.branch_names.find { |n| n != 'existing-branch' }
- create(:protected_branch, project: project, name: branch_name)
+ expect(remote_mirror).to be_failed
+ expect(remote_mirror.last_error).to include("Some refs have diverged")
+ expect(remote_mirror.last_error).to include("refs/heads/master\n")
+ expect(remote_mirror.last_error).to include("refs/heads/develop")
end
end
end
diff --git a/spec/services/projects/update_repository_storage_service_spec.rb b/spec/services/projects/update_repository_storage_service_spec.rb
index 05555fa76f7..28b79bc61d9 100644
--- a/spec/services/projects/update_repository_storage_service_spec.rb
+++ b/spec/services/projects/update_repository_storage_service_spec.rb
@@ -5,17 +5,20 @@ require 'spec_helper'
describe Projects::UpdateRepositoryStorageService do
include Gitlab::ShellAdapter
- subject { described_class.new(project) }
+ subject { described_class.new(repository_storage_move) }
describe "#execute" do
- let(:time) { Time.now }
+ let(:time) { Time.current }
before do
allow(Time).to receive(:now).and_return(time)
+ allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(%w[default test_second_storage])
end
context 'without wiki and design repository' do
let(:project) { create(:project, :repository, repository_read_only: true, wiki_enabled: false) }
+ let(:destination) { 'test_second_storage' }
+ let(:repository_storage_move) { create(:project_repository_storage_move, :scheduled, project: project, destination_storage_name: destination) }
let!(:checksum) { project.repository.checksum }
let(:project_repository_double) { double(:repository) }
@@ -41,9 +44,9 @@ describe Projects::UpdateRepositoryStorageService do
expect(project_repository_double).to receive(:checksum)
.and_return(checksum)
- result = subject.execute('test_second_storage')
+ result = subject.execute
- expect(result[:status]).to eq(:success)
+ expect(result).to be_success
expect(project).not_to be_repository_read_only
expect(project.repository_storage).to eq('test_second_storage')
expect(gitlab_shell.repository_exists?('default', old_path)).to be(false)
@@ -52,11 +55,13 @@ describe Projects::UpdateRepositoryStorageService do
end
context 'when the filesystems are the same' do
+ let(:destination) { project.repository_storage }
+
it 'bails out and does nothing' do
- result = subject.execute(project.repository_storage)
+ result = subject.execute
- expect(result[:status]).to eq(:error)
- expect(result[:message]).to match(/SameFilesystemError/)
+ expect(result).to be_error
+ expect(result.message).to match(/SameFilesystemError/)
end
end
@@ -72,9 +77,9 @@ describe Projects::UpdateRepositoryStorageService do
.and_raise(Gitlab::Git::CommandError)
expect(GitlabShellWorker).not_to receive(:perform_async)
- result = subject.execute('test_second_storage')
+ result = subject.execute
- expect(result[:status]).to eq(:error)
+ expect(result).to be_error
expect(project).not_to be_repository_read_only
expect(project.repository_storage).to eq('default')
end
@@ -93,9 +98,9 @@ describe Projects::UpdateRepositoryStorageService do
.and_return('not matching checksum')
expect(GitlabShellWorker).not_to receive(:perform_async)
- result = subject.execute('test_second_storage')
+ result = subject.execute
- expect(result[:status]).to eq(:error)
+ expect(result).to be_error
expect(project).not_to be_repository_read_only
expect(project.repository_storage).to eq('default')
end
@@ -115,9 +120,9 @@ describe Projects::UpdateRepositoryStorageService do
expect(project_repository_double).to receive(:checksum)
.and_return(checksum)
- result = subject.execute('test_second_storage')
+ result = subject.execute
- expect(result[:status]).to eq(:success)
+ expect(result).to be_success
expect(project.repository_storage).to eq('test_second_storage')
expect(project.reload_pool_repository).to be_nil
end
@@ -128,11 +133,26 @@ describe Projects::UpdateRepositoryStorageService do
include_examples 'moves repository to another storage', 'wiki' do
let(:project) { create(:project, :repository, repository_read_only: true, wiki_enabled: true) }
let(:repository) { project.wiki.repository }
+ let(:destination) { 'test_second_storage' }
+ let(:repository_storage_move) { create(:project_repository_storage_move, :scheduled, project: project, destination_storage_name: destination) }
before do
project.create_wiki
end
end
end
+
+ context 'with design repository' do
+ include_examples 'moves repository to another storage', 'design' do
+ let(:project) { create(:project, :repository, repository_read_only: true) }
+ let(:repository) { project.design_repository }
+ let(:destination) { 'test_second_storage' }
+ let(:repository_storage_move) { create(:project_repository_storage_move, :scheduled, project: project, destination_storage_name: destination) }
+
+ before do
+ project.design_repository.create_if_not_exists
+ end
+ end
+ end
end
end
diff --git a/spec/services/prometheus/proxy_service_spec.rb b/spec/services/prometheus/proxy_service_spec.rb
index 5a036194d01..656ccea10de 100644
--- a/spec/services/prometheus/proxy_service_spec.rb
+++ b/spec/services/prometheus/proxy_service_spec.rb
@@ -117,7 +117,7 @@ describe Prometheus::ProxyService do
context 'when value not present in cache' do
it 'returns nil' do
- expect(ReactiveCachingWorker)
+ expect(ExternalServiceReactiveCachingWorker)
.to receive(:perform_async)
.with(subject.class, subject.id, *opts)
diff --git a/spec/services/prometheus/proxy_variable_substitution_service_spec.rb b/spec/services/prometheus/proxy_variable_substitution_service_spec.rb
index 9978c631366..82ea356d599 100644
--- a/spec/services/prometheus/proxy_variable_substitution_service_spec.rb
+++ b/spec/services/prometheus/proxy_variable_substitution_service_spec.rb
@@ -6,7 +6,7 @@ describe Prometheus::ProxyVariableSubstitutionService do
describe '#execute' do
let_it_be(:environment) { create(:environment) }
- let(:params_keys) { { query: 'up{environment="%{ci_environment_slug}"}' } }
+ let(:params_keys) { { query: 'up{environment="{{ci_environment_slug}}"}' } }
let(:params) { ActionController::Parameters.new(params_keys).permit! }
let(:result) { subject.execute }
@@ -32,21 +32,13 @@ describe Prometheus::ProxyVariableSubstitutionService do
expect(params).to eq(
ActionController::Parameters.new(
- query: 'up{environment="%{ci_environment_slug}"}'
+ query: 'up{environment="{{ci_environment_slug}}"}'
).permit!
)
end
end
context 'with predefined variables' do
- let(:params_keys) { { query: 'up{%{environment_filter}}' } }
-
- it_behaves_like 'success' do
- let(:expected_query) do
- %Q[up{container_name!="POD",environment="#{environment.slug}"}]
- end
- end
-
context 'with nil query' do
let(:params_keys) { {} }
@@ -64,18 +56,6 @@ describe Prometheus::ProxyVariableSubstitutionService do
let(:expected_query) { %Q[up{environment="#{environment.slug}"}] }
end
end
-
- context 'with ruby and liquid formats' do
- let(:params_keys) do
- { query: 'up{%{environment_filter},env2="{{ci_environment_slug}}"}' }
- end
-
- it_behaves_like 'success' do
- let(:expected_query) do
- %Q[up{container_name!="POD",environment="#{environment.slug}",env2="#{environment.slug}"}]
- end
- end
- end
end
context 'with custom variables' do
@@ -92,20 +72,6 @@ describe Prometheus::ProxyVariableSubstitutionService do
let(:expected_query) { %q[up{pod_name="pod1"}] }
end
- context 'with ruby variable interpolation format' do
- let(:params_keys) do
- {
- query: 'up{pod_name="%{pod_name}"}',
- variables: ['pod_name', pod_name]
- }
- end
-
- it_behaves_like 'success' do
- # Custom variables cannot be used with the Ruby interpolation format.
- let(:expected_query) { "up{pod_name=\"%{pod_name}\"}" }
- end
- end
-
context 'with predefined variables in variables parameter' do
let(:params_keys) do
{
@@ -142,62 +108,47 @@ describe Prometheus::ProxyVariableSubstitutionService do
end
it_behaves_like 'success' do
- let(:expected_query) { 'up{pod_name=""}' }
+ let(:expected_query) { 'up{pod_name="{{pod_name}}"}' }
end
end
+ end
- context 'with ruby and liquid variables' do
+ context 'gsub variable substitution tolerance for weirdness' do
+ context 'with whitespace around variable' do
let(:params_keys) do
{
- query: 'up{env1="%{ruby_variable}",env2="{{ liquid_variable }}"}',
- variables: %w(ruby_variable value liquid_variable env_slug)
+ query: 'up{' \
+ "env1={{ ci_environment_slug}}," \
+ "env2={{ci_environment_slug }}," \
+ "{{ environment_filter }}" \
+ '}'
}
end
it_behaves_like 'success' do
- # It should replace only liquid variables with their values
- let(:expected_query) { %q[up{env1="%{ruby_variable}",env2="env_slug"}] }
+ let(:expected_query) do
+ 'up{' \
+ "env1=#{environment.slug}," \
+ "env2=#{environment.slug}," \
+ "container_name!=\"POD\",environment=\"#{environment.slug}\"" \
+ '}'
+ end
end
end
- end
-
- context 'with liquid tags and ruby format variables' do
- let(:params_keys) do
- {
- query: 'up{ {% if true %}env1="%{ci_environment_slug}",' \
- 'env2="{{ci_environment_slug}}"{% endif %} }'
- }
- end
-
- # The following spec will fail and should be changed to a 'success' spec
- # once we remove support for the Ruby interpolation format.
- # https://gitlab.com/gitlab-org/gitlab/issues/37990
- #
- # Liquid tags `{% %}` cannot be used currently because the Ruby `%`
- # operator raises an error when it encounters a Liquid `{% %}` tag in the
- # string.
- #
- # Once we remove support for the Ruby format, users can start using
- # Liquid tags.
-
- it_behaves_like 'error', 'Malformed string'
- end
- context 'ruby template rendering' do
- let(:params_keys) do
- { query: 'up{env=%{ci_environment_slug},%{environment_filter}}' }
- end
+ context 'with empty variables' do
+ let(:params_keys) do
+ { query: "up{env1={{}},env2={{ }}}" }
+ end
- it_behaves_like 'success' do
- let(:expected_query) do
- "up{env=#{environment.slug},container_name!=\"POD\"," \
- "environment=\"#{environment.slug}\"}"
+ it_behaves_like 'success' do
+ let(:expected_query) { "up{env1={{}},env2={{ }}}" }
end
end
context 'with multiple occurrences of variable in string' do
let(:params_keys) do
- { query: 'up{env1=%{ci_environment_slug},env2=%{ci_environment_slug}}' }
+ { query: "up{env1={{ci_environment_slug}},env2={{ci_environment_slug}}}" }
end
it_behaves_like 'success' do
@@ -207,7 +158,7 @@ describe Prometheus::ProxyVariableSubstitutionService do
context 'with multiple variables in string' do
let(:params_keys) do
- { query: 'up{env=%{ci_environment_slug},%{environment_filter}}' }
+ { query: "up{env={{ci_environment_slug}},{{environment_filter}}}" }
end
it_behaves_like 'success' do
@@ -219,69 +170,22 @@ describe Prometheus::ProxyVariableSubstitutionService do
end
context 'with unknown variables in string' do
- let(:params_keys) { { query: 'up{env=%{env_slug}}' } }
-
- it_behaves_like 'success' do
- let(:expected_query) { 'up{env=%{env_slug}}' }
- end
- end
-
- # This spec is needed if there are multiple keys in the context provided
- # by `Gitlab::Prometheus::QueryVariables.call(environment)` which is
- # passed to the Ruby `%` operator.
- # If the number of keys in the context is one, there is no need for
- # this spec.
- context 'with extra variables in context' do
- let(:params_keys) { { query: 'up{env=%{ci_environment_slug}}' } }
+ let(:params_keys) { { query: "up{env={{env_slug}}}" } }
it_behaves_like 'success' do
- let(:expected_query) { "up{env=#{environment.slug}}" }
- end
-
- it 'has more than one variable in context' do
- expect(Gitlab::Prometheus::QueryVariables.call(environment).size).to be > 1
+ let(:expected_query) { "up{env={{env_slug}}}" }
end
end
- # The ruby % operator will not replace known variables if there are unknown
- # variables also in the string. It doesn't raise an error
- # (though the `sprintf` and `format` methods do).
context 'with unknown and known variables in string' do
let(:params_keys) do
- { query: 'up{env=%{ci_environment_slug},other_env=%{env_slug}}' }
+ { query: "up{env={{ci_environment_slug}},other_env={{env_slug}}}" }
end
it_behaves_like 'success' do
- let(:expected_query) { 'up{env=%{ci_environment_slug},other_env=%{env_slug}}' }
+ let(:expected_query) { "up{env=#{environment.slug},other_env={{env_slug}}}" }
end
end
-
- context 'when rendering raises error' do
- context 'when TypeError is raised' do
- let(:params_keys) { { query: '{% a %}' } }
-
- it_behaves_like 'error', 'Malformed string'
- end
-
- context 'when ArgumentError is raised' do
- let(:params_keys) { { query: '%<' } }
-
- it_behaves_like 'error', 'Malformed string'
- end
- end
- end
-
- context 'when liquid template rendering raises error' do
- before do
- liquid_service = instance_double(TemplateEngines::LiquidService)
-
- allow(TemplateEngines::LiquidService).to receive(:new).and_return(liquid_service)
- allow(liquid_service).to receive(:render).and_raise(
- TemplateEngines::LiquidService::RenderError, 'error message'
- )
- end
-
- it_behaves_like 'error', 'error message'
end
end
end
diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb
index 36f9966c0ef..a9de0a747f6 100644
--- a/spec/services/quick_actions/interpret_service_spec.rb
+++ b/spec/services/quick_actions/interpret_service_spec.rb
@@ -361,7 +361,7 @@ describe QuickActions::InterpretService do
expect(updates).to eq(spend_time: {
duration: 3600,
user_id: developer.id,
- spent_at: DateTime.now.to_date
+ spent_at: DateTime.current.to_date
})
end
@@ -379,7 +379,7 @@ describe QuickActions::InterpretService do
expect(updates).to eq(spend_time: {
duration: -1800,
user_id: developer.id,
- spent_at: DateTime.now.to_date
+ spent_at: DateTime.current.to_date
})
end
end
diff --git a/spec/services/releases/create_service_spec.rb b/spec/services/releases/create_service_spec.rb
index 255f044db90..d0859500440 100644
--- a/spec/services/releases/create_service_spec.rb
+++ b/spec/services/releases/create_service_spec.rb
@@ -20,6 +20,8 @@ describe Releases::CreateService do
describe '#execute' do
shared_examples 'a successful release creation' do
it 'creates a new release' do
+ expected_job_count = MailScheduler::NotificationServiceWorker.jobs.size + 1
+
result = service.execute
expect(project.releases.count).to eq(1)
@@ -30,6 +32,7 @@ describe Releases::CreateService do
expect(result[:release].name).to eq(name)
expect(result[:release].author).to eq(user)
expect(result[:release].sha).to eq(tag_sha)
+ expect(MailScheduler::NotificationServiceWorker.jobs.size).to eq(expected_job_count)
end
end
diff --git a/spec/services/repository_archive_clean_up_service_spec.rb b/spec/services/repository_archive_clean_up_service_spec.rb
index c0b286ac675..80b177a0174 100644
--- a/spec/services/repository_archive_clean_up_service_spec.rb
+++ b/spec/services/repository_archive_clean_up_service_spec.rb
@@ -110,6 +110,8 @@ describe RepositoryArchiveCleanUpService do
def create_temporary_files(dir, extensions, mtime)
FileUtils.mkdir_p(dir)
+ # rubocop: disable Rails/TimeZone
FileUtils.touch(extensions.map { |ext| File.join(dir, "sample.#{ext}") }, mtime: Time.now - mtime)
+ # rubocop: enable Rails/TimeZone
end
end
diff --git a/spec/services/resource_access_tokens/create_service_spec.rb b/spec/services/resource_access_tokens/create_service_spec.rb
new file mode 100644
index 00000000000..57e7e4e66de
--- /dev/null
+++ b/spec/services/resource_access_tokens/create_service_spec.rb
@@ -0,0 +1,163 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ResourceAccessTokens::CreateService do
+ subject { described_class.new(user, resource, params).execute }
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:params) { {} }
+
+ describe '#execute' do
+ # Created shared_examples as it will easy to include specs for group bots in https://gitlab.com/gitlab-org/gitlab/-/issues/214046
+ shared_examples 'fails when user does not have the permission to create a Resource Bot' do
+ before_all do
+ resource.add_developer(user)
+ end
+
+ it 'returns error' do
+ response = subject
+
+ expect(response.error?).to be true
+ expect(response.message).to eq("User does not have permission to create #{resource_type} Access Token")
+ end
+ end
+
+ shared_examples 'fails when flag is disabled' do
+ before do
+ stub_feature_flags(resource_access_token: false)
+ end
+
+ it 'returns nil' do
+ expect(subject).to be nil
+ end
+ end
+
+ shared_examples 'allows creation of bot with valid params' do
+ it { expect { subject }.to change { User.count }.by(1) }
+
+ it 'creates resource bot user' do
+ response = subject
+
+ access_token = response.payload[:access_token]
+
+ expect(access_token.user.reload.user_type).to eq("#{resource_type}_bot")
+ end
+
+ context 'bot name' do
+ context 'when no value is passed' do
+ it 'uses default value' do
+ response = subject
+ access_token = response.payload[:access_token]
+
+ expect(access_token.user.name).to eq("#{resource.name.to_s.humanize} bot")
+ end
+ end
+
+ context 'when user provides value' do
+ let_it_be(:params) { { name: 'Random bot' } }
+
+ it 'overrides the default value' do
+ response = subject
+ access_token = response.payload[:access_token]
+
+ expect(access_token.user.name).to eq(params[:name])
+ end
+ end
+ end
+
+ it 'adds the bot user as a maintainer in the resource' do
+ response = subject
+ access_token = response.payload[:access_token]
+ bot_user = access_token.user
+
+ expect(resource.members.maintainers.map(&:user_id)).to include(bot_user.id)
+ end
+
+ context 'personal access token' do
+ it { expect { subject }.to change { PersonalAccessToken.count }.by(1) }
+
+ context 'when user does not provide scope' do
+ it 'has default scopes' do
+ response = subject
+ access_token = response.payload[:access_token]
+
+ expect(access_token.scopes).to eq(Gitlab::Auth.resource_bot_scopes)
+ end
+ end
+
+ context 'when user provides scope explicitly' do
+ let_it_be(:params) { { scopes: Gitlab::Auth::REPOSITORY_SCOPES } }
+
+ it 'overrides the default value' do
+ response = subject
+ access_token = response.payload[:access_token]
+
+ expect(access_token.scopes).to eq(Gitlab::Auth::REPOSITORY_SCOPES)
+ end
+ end
+
+ context 'expires_at' do
+ context 'when no value is passed' do
+ it 'uses default value' do
+ response = subject
+ access_token = response.payload[:access_token]
+
+ expect(access_token.expires_at).to eq(nil)
+ end
+ end
+
+ context 'when user provides value' do
+ let_it_be(:params) { { expires_at: Date.today + 1.month } }
+
+ it 'overrides the default value' do
+ response = subject
+ access_token = response.payload[:access_token]
+
+ expect(access_token.expires_at).to eq(params[:expires_at])
+ end
+ end
+
+ context 'when invalid scope is passed' do
+ let_it_be(:params) { { scopes: [:invalid_scope] } }
+
+ it 'returns error' do
+ response = subject
+
+ expect(response.error?).to be true
+ end
+ end
+ end
+ end
+
+ context 'when access provisioning fails' do
+ before do
+ allow(resource).to receive(:add_maintainer).and_return(nil)
+ end
+
+ it 'returns error' do
+ response = subject
+
+ expect(response.error?).to be true
+ end
+ end
+ end
+
+ context 'when resource is a project' do
+ let_it_be(:resource_type) { 'project' }
+ let_it_be(:resource) { project }
+
+ it_behaves_like 'fails when user does not have the permission to create a Resource Bot'
+ it_behaves_like 'fails when flag is disabled'
+
+ context 'user with valid permission' do
+ before_all do
+ resource.add_maintainer(user)
+ end
+
+ it_behaves_like 'allows creation of bot with valid params'
+ end
+ end
+ end
+end
diff --git a/spec/services/resource_access_tokens/revoke_service_spec.rb b/spec/services/resource_access_tokens/revoke_service_spec.rb
new file mode 100644
index 00000000000..3ce82745b9e
--- /dev/null
+++ b/spec/services/resource_access_tokens/revoke_service_spec.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ResourceAccessTokens::RevokeService do
+ subject { described_class.new(user, resource, access_token).execute }
+
+ let_it_be(:user) { create(:user) }
+ let(:access_token) { create(:personal_access_token, user: resource_bot) }
+
+ describe '#execute' do
+ # Created shared_examples as it will easy to include specs for group bots in https://gitlab.com/gitlab-org/gitlab/-/issues/214046
+ shared_examples 'revokes access token' do
+ it { expect(subject.success?).to be true }
+
+ it { expect(subject.message).to eq("Revoked access token: #{access_token.name}") }
+
+ it 'revokes token access' do
+ subject
+
+ expect(access_token.reload.revoked?).to be true
+ end
+
+ it 'removes membership of bot user' do
+ subject
+
+ expect(resource.reload.users).not_to include(resource_bot)
+ end
+
+ it 'transfer issuables of bot user to ghost user' do
+ issue = create(:issue, author: resource_bot)
+
+ subject
+
+ expect(issue.reload.author.ghost?).to be true
+ end
+ end
+
+ shared_examples 'rollback revoke steps' do
+ it 'does not revoke the access token' do
+ subject
+
+ expect(access_token.reload.revoked?).to be false
+ end
+
+ it 'does not remove bot from member list' do
+ subject
+
+ expect(resource.reload.users).to include(resource_bot)
+ end
+
+ it 'does not transfer issuables of bot user to ghost user' do
+ issue = create(:issue, author: resource_bot)
+
+ subject
+
+ expect(issue.reload.author.ghost?).to be false
+ end
+ end
+
+ context 'when resource is a project' do
+ let_it_be(:resource) { create(:project, :private) }
+ let_it_be(:resource_bot) { create(:user, :project_bot) }
+
+ before_all do
+ resource.add_maintainer(user)
+ resource.add_maintainer(resource_bot)
+ end
+
+ it_behaves_like 'revokes access token'
+
+ context 'when revoke fails' do
+ context 'invalid resource type' do
+ subject { described_class.new(user, resource, access_token).execute }
+
+ let_it_be(:resource) { double }
+ let_it_be(:resource_bot) { create(:user, :project_bot) }
+
+ it 'returns error response' do
+ response = subject
+
+ expect(response.success?).to be false
+ expect(response.message).to eq("Failed to find bot user")
+ end
+
+ it { expect { subject }.not_to change(access_token.reload, :revoked) }
+ end
+
+ context 'when migration to ghost user fails' do
+ before do
+ allow_next_instance_of(::Members::DestroyService) do |service|
+ allow(service).to receive(:execute).and_return(false)
+ end
+ end
+
+ it_behaves_like 'rollback revoke steps'
+ end
+
+ context 'when migration to ghost user fails' do
+ before do
+ allow_next_instance_of(::Users::MigrateToGhostUserService) do |service|
+ allow(service).to receive(:execute).and_return(false)
+ end
+ end
+
+ it_behaves_like 'rollback revoke steps'
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/resource_events/change_milestone_service_spec.rb b/spec/services/resource_events/change_milestone_service_spec.rb
index bc634fadadd..dec01d0db8d 100644
--- a/spec/services/resource_events/change_milestone_service_spec.rb
+++ b/spec/services/resource_events/change_milestone_service_spec.rb
@@ -3,11 +3,9 @@
require 'spec_helper'
describe ResourceEvents::ChangeMilestoneService do
- it_behaves_like 'a milestone events creator' do
- let(:resource) { create(:issue) }
- end
-
- it_behaves_like 'a milestone events creator' do
- let(:resource) { create(:merge_request) }
+ [:issue, :merge_request].each do |issuable|
+ it_behaves_like 'a milestone events creator' do
+ let(:resource) { create(issuable) }
+ end
end
end
diff --git a/spec/services/resource_events/merge_into_notes_service_spec.rb b/spec/services/resource_events/merge_into_notes_service_spec.rb
index 6bad1b86fca..2664a27244d 100644
--- a/spec/services/resource_events/merge_into_notes_service_spec.rb
+++ b/spec/services/resource_events/merge_into_notes_service_spec.rb
@@ -21,7 +21,7 @@ describe ResourceEvents::MergeIntoNotesService do
let_it_be(:resource) { create(:issue, project: project) }
let_it_be(:label) { create(:label, project: project) }
let_it_be(:label2) { create(:label, project: project) }
- let(:time) { Time.now }
+ let(:time) { Time.current }
describe '#execute' do
it 'merges label events into notes in order of created_at' do
diff --git a/spec/services/resources/create_access_token_service_spec.rb b/spec/services/resources/create_access_token_service_spec.rb
deleted file mode 100644
index 8c108d9937a..00000000000
--- a/spec/services/resources/create_access_token_service_spec.rb
+++ /dev/null
@@ -1,163 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe Resources::CreateAccessTokenService do
- subject { described_class.new(resource_type, resource, user, params).execute }
-
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project, :private) }
- let_it_be(:params) { {} }
-
- describe '#execute' do
- # Created shared_examples as it will easy to include specs for group bots in https://gitlab.com/gitlab-org/gitlab/-/issues/214046
- shared_examples 'fails when user does not have the permission to create a Resource Bot' do
- before do
- resource.add_developer(user)
- end
-
- it 'returns error' do
- response = subject
-
- expect(response.error?).to be true
- expect(response.message).to eq("User does not have permission to create #{resource_type} Access Token")
- end
- end
-
- shared_examples 'fails when flag is disabled' do
- before do
- stub_feature_flags(resource_access_token: false)
- end
-
- it 'returns nil' do
- expect(subject).to be nil
- end
- end
-
- shared_examples 'allows creation of bot with valid params' do
- it { expect { subject }.to change { User.count }.by(1) }
-
- it 'creates resource bot user' do
- response = subject
-
- access_token = response.payload[:access_token]
-
- expect(access_token.user.reload.user_type).to eq("#{resource_type}_bot")
- end
-
- context 'bot name' do
- context 'when no value is passed' do
- it 'uses default value' do
- response = subject
- access_token = response.payload[:access_token]
-
- expect(access_token.user.name).to eq("#{resource.name.to_s.humanize} bot")
- end
- end
-
- context 'when user provides value' do
- let(:params) { { name: 'Random bot' } }
-
- it 'overrides the default value' do
- response = subject
- access_token = response.payload[:access_token]
-
- expect(access_token.user.name).to eq(params[:name])
- end
- end
- end
-
- it 'adds the bot user as a maintainer in the resource' do
- response = subject
- access_token = response.payload[:access_token]
- bot_user = access_token.user
-
- expect(resource.members.maintainers.map(&:user_id)).to include(bot_user.id)
- end
-
- context 'personal access token' do
- it { expect { subject }.to change { PersonalAccessToken.count }.by(1) }
-
- context 'when user does not provide scope' do
- it 'has default scopes' do
- response = subject
- access_token = response.payload[:access_token]
-
- expect(access_token.scopes).to eq(Gitlab::Auth::API_SCOPES + Gitlab::Auth::REPOSITORY_SCOPES + Gitlab::Auth.registry_scopes - [:read_user])
- end
- end
-
- context 'when user provides scope explicitly' do
- let(:params) { { scopes: Gitlab::Auth::REPOSITORY_SCOPES } }
-
- it 'overrides the default value' do
- response = subject
- access_token = response.payload[:access_token]
-
- expect(access_token.scopes).to eq(Gitlab::Auth::REPOSITORY_SCOPES)
- end
- end
-
- context 'expires_at' do
- context 'when no value is passed' do
- it 'uses default value' do
- response = subject
- access_token = response.payload[:access_token]
-
- expect(access_token.expires_at).to eq(nil)
- end
- end
-
- context 'when user provides value' do
- let(:params) { { expires_at: Date.today + 1.month } }
-
- it 'overrides the default value' do
- response = subject
- access_token = response.payload[:access_token]
-
- expect(access_token.expires_at).to eq(params[:expires_at])
- end
- end
-
- context 'when invalid scope is passed' do
- let(:params) { { scopes: [:invalid_scope] } }
-
- it 'returns error' do
- response = subject
-
- expect(response.error?).to be true
- end
- end
- end
- end
-
- context 'when access provisioning fails' do
- before do
- allow(resource).to receive(:add_maintainer).and_return(nil)
- end
-
- it 'returns error' do
- response = subject
-
- expect(response.error?).to be true
- end
- end
- end
-
- context 'when resource is a project' do
- let(:resource_type) { 'project' }
- let(:resource) { project }
-
- it_behaves_like 'fails when user does not have the permission to create a Resource Bot'
- it_behaves_like 'fails when flag is disabled'
-
- context 'user with valid permission' do
- before do
- resource.add_maintainer(user)
- end
-
- it_behaves_like 'allows creation of bot with valid params'
- end
- end
- end
-end
diff --git a/spec/services/search/snippet_service_spec.rb b/spec/services/search/snippet_service_spec.rb
index 430c71880a3..cb2bb0c43fd 100644
--- a/spec/services/search/snippet_service_spec.rb
+++ b/spec/services/search/snippet_service_spec.rb
@@ -3,59 +3,67 @@
require 'spec_helper'
describe Search::SnippetService do
- let(:author) { create(:author) }
- let(:project) { create(:project, :public) }
+ let_it_be(:author) { create(:author) }
+ let_it_be(:project) { create(:project, :public) }
- let!(:public_snippet) { create(:snippet, :public, content: 'password: XXX') }
- let!(:internal_snippet) { create(:snippet, :internal, content: 'password: XXX') }
- let!(:private_snippet) { create(:snippet, :private, content: 'password: XXX', author: author) }
+ let_it_be(:public_snippet) { create(:snippet, :public, title: 'Foo Bar Title') }
+ let_it_be(:internal_snippet) { create(:snippet, :internal, title: 'Foo Bar Title') }
+ let_it_be(:private_snippet) { create(:snippet, :private, title: 'Foo Bar Title', author: author) }
- let!(:project_public_snippet) { create(:snippet, :public, project: project, content: 'password: XXX') }
- let!(:project_internal_snippet) { create(:snippet, :internal, project: project, content: 'password: XXX') }
- let!(:project_private_snippet) { create(:snippet, :private, project: project, content: 'password: XXX') }
+ let_it_be(:project_public_snippet) { create(:snippet, :public, project: project, title: 'Foo Bar Title') }
+ let_it_be(:project_internal_snippet) { create(:snippet, :internal, project: project, title: 'Foo Bar Title') }
+ let_it_be(:project_private_snippet) { create(:snippet, :private, project: project, title: 'Foo Bar Title') }
+
+ let_it_be(:user) { create(:user) }
describe '#execute' do
context 'unauthenticated' do
it 'returns public snippets only' do
- search = described_class.new(nil, search: 'password')
+ search = described_class.new(nil, search: 'bar')
results = search.execute
- expect(results.objects('snippet_blobs')).to match_array [public_snippet, project_public_snippet]
+ expect(results.objects('snippet_titles')).to match_array [public_snippet, project_public_snippet]
end
end
context 'authenticated' do
it 'returns only public & internal snippets for regular users' do
- user = create(:user)
- search = described_class.new(user, search: 'password')
+ search = described_class.new(user, search: 'bar')
results = search.execute
- expect(results.objects('snippet_blobs')).to match_array [public_snippet, internal_snippet, project_public_snippet, project_internal_snippet]
+ expect(results.objects('snippet_titles')).to match_array [public_snippet, internal_snippet, project_public_snippet, project_internal_snippet]
end
it 'returns public, internal snippets and project private snippets for project members' do
- member = create(:user)
- project.add_developer(member)
- search = described_class.new(member, search: 'password')
+ project.add_developer(user)
+ search = described_class.new(user, search: 'bar')
results = search.execute
- expect(results.objects('snippet_blobs')).to match_array [public_snippet, internal_snippet, project_public_snippet, project_internal_snippet, project_private_snippet]
+ expect(results.objects('snippet_titles')).to match_array [public_snippet, internal_snippet, project_public_snippet, project_internal_snippet, project_private_snippet]
end
it 'returns public, internal and private snippets where user is the author' do
- search = described_class.new(author, search: 'password')
+ search = described_class.new(author, search: 'bar')
results = search.execute
- expect(results.objects('snippet_blobs')).to match_array [public_snippet, internal_snippet, private_snippet, project_public_snippet, project_internal_snippet]
+ expect(results.objects('snippet_titles')).to match_array [public_snippet, internal_snippet, private_snippet, project_public_snippet, project_internal_snippet]
end
it 'returns all snippets when user is admin' do
admin = create(:admin)
- search = described_class.new(admin, search: 'password')
+ search = described_class.new(admin, search: 'bar')
results = search.execute
- expect(results.objects('snippet_blobs')).to match_array [public_snippet, internal_snippet, private_snippet, project_public_snippet, project_internal_snippet, project_private_snippet]
+ expect(results.objects('snippet_titles')).to match_array [public_snippet, internal_snippet, private_snippet, project_public_snippet, project_internal_snippet, project_private_snippet]
end
end
end
+
+ describe '#scope' do
+ it 'always scopes to snippet_titles' do
+ search = described_class.new(user, search: 'bar')
+
+ expect(search.scope).to eq 'snippet_titles'
+ end
+ end
end
diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb
index 97d7ca6e1ad..0333eb85fb6 100644
--- a/spec/services/search_service_spec.rb
+++ b/spec/services/search_service_spec.rb
@@ -18,7 +18,9 @@ describe SearchService do
let(:group_project) { create(:project, group: accessible_group, name: 'group_project') }
let(:public_project) { create(:project, :public, name: 'public_project') }
- subject(:search_service) { described_class.new(user, search: search, scope: scope, page: 1) }
+ let(:per_page) { described_class::DEFAULT_PER_PAGE }
+
+ subject(:search_service) { described_class.new(user, search: search, scope: scope, page: 1, per_page: per_page) }
before do
accessible_project.add_maintainer(user)
@@ -151,7 +153,7 @@ describe SearchService do
it 'returns the default scope' do
scope = described_class.new(user, snippets: 'true', scope: 'projects').scope
- expect(scope).to eq 'snippet_blobs'
+ expect(scope).to eq 'snippet_titles'
end
end
@@ -159,7 +161,7 @@ describe SearchService do
it 'returns the default scope' do
scope = described_class.new(user, snippets: 'true').scope
- expect(scope).to eq 'snippet_blobs'
+ expect(scope).to eq 'snippet_titles'
end
end
end
@@ -222,7 +224,7 @@ describe SearchService do
search_results = described_class.new(
user,
snippets: 'true',
- search: snippet.content).search_results
+ search: snippet.title).search_results
expect(search_results).to be_a Gitlab::SnippetSearchResults
end
@@ -240,6 +242,76 @@ describe SearchService do
end
describe '#search_objects' do
+ context 'handling per_page param' do
+ let(:search) { '' }
+ let(:scope) { nil }
+
+ context 'when nil' do
+ let(:per_page) { nil }
+
+ it "defaults to #{described_class::DEFAULT_PER_PAGE}" do
+ expect_any_instance_of(Gitlab::SearchResults)
+ .to receive(:objects)
+ .with(anything, hash_including(per_page: described_class::DEFAULT_PER_PAGE))
+ .and_call_original
+
+ subject.search_objects
+ end
+ end
+
+ context 'when empty string' do
+ let(:per_page) { '' }
+
+ it "defaults to #{described_class::DEFAULT_PER_PAGE}" do
+ expect_any_instance_of(Gitlab::SearchResults)
+ .to receive(:objects)
+ .with(anything, hash_including(per_page: described_class::DEFAULT_PER_PAGE))
+ .and_call_original
+
+ subject.search_objects
+ end
+ end
+
+ context 'when negative' do
+ let(:per_page) { '-1' }
+
+ it "defaults to #{described_class::DEFAULT_PER_PAGE}" do
+ expect_any_instance_of(Gitlab::SearchResults)
+ .to receive(:objects)
+ .with(anything, hash_including(per_page: described_class::DEFAULT_PER_PAGE))
+ .and_call_original
+
+ subject.search_objects
+ end
+ end
+
+ context 'when present' do
+ let(:per_page) { '50' }
+
+ it "converts to integer and passes to search results" do
+ expect_any_instance_of(Gitlab::SearchResults)
+ .to receive(:objects)
+ .with(anything, hash_including(per_page: 50))
+ .and_call_original
+
+ subject.search_objects
+ end
+ end
+
+ context "when greater than #{described_class::MAX_PER_PAGE}" do
+ let(:per_page) { described_class::MAX_PER_PAGE + 1 }
+
+ it "passes #{described_class::MAX_PER_PAGE}" do
+ expect_any_instance_of(Gitlab::SearchResults)
+ .to receive(:objects)
+ .with(anything, hash_including(per_page: described_class::MAX_PER_PAGE))
+ .and_call_original
+
+ subject.search_objects
+ end
+ end
+ end
+
context 'with accessible project_id' do
it 'returns objects in the project' do
search_objects = described_class.new(
@@ -270,7 +342,7 @@ describe SearchService do
search_objects = described_class.new(
user,
snippets: 'true',
- search: snippet.content).search_objects
+ search: snippet.title).search_objects
expect(search_objects.first).to eq snippet
end
@@ -383,7 +455,7 @@ describe SearchService do
let(:readable) { create(:project_snippet, project: accessible_project) }
let(:unreadable) { create(:project_snippet, project: inaccessible_project) }
let(:unredacted_results) { ar_relation(ProjectSnippet, readable, unreadable) }
- let(:scope) { 'snippet_blobs' }
+ let(:scope) { 'snippet_titles' }
it 'redacts the inaccessible snippet' do
expect(result).to contain_exactly(readable)
@@ -394,7 +466,7 @@ describe SearchService do
let(:readable) { create(:personal_snippet, :private, author: user) }
let(:unreadable) { create(:personal_snippet, :private) }
let(:unredacted_results) { ar_relation(PersonalSnippet, readable, unreadable) }
- let(:scope) { 'snippet_blobs' }
+ let(:scope) { 'snippet_titles' }
it 'redacts the inaccessible snippet' do
expect(result).to contain_exactly(readable)
diff --git a/spec/services/snippets/create_service_spec.rb b/spec/services/snippets/create_service_spec.rb
index c1a8a026b90..786fc3ec8dd 100644
--- a/spec/services/snippets/create_service_spec.rb
+++ b/spec/services/snippets/create_service_spec.rb
@@ -74,47 +74,6 @@ describe Snippets::CreateService do
end
end
- shared_examples 'spam check is performed' do
- shared_examples 'marked as spam' do
- it 'marks a snippet as spam' do
- expect(snippet).to be_spam
- end
-
- it 'invalidates the snippet' do
- expect(snippet).to be_invalid
- end
-
- it 'creates a new spam_log' do
- expect { snippet }
- .to have_spam_log(title: snippet.title, noteable_type: snippet.class.name)
- end
-
- it 'assigns a spam_log to an issue' do
- expect(snippet.spam_log).to eq(SpamLog.last)
- end
- end
-
- let(:extra_opts) do
- { visibility_level: Gitlab::VisibilityLevel::PUBLIC, request: double(:request, env: {}) }
- end
-
- before do
- expect_next_instance_of(Spam::AkismetService) do |akismet_service|
- expect(akismet_service).to receive_messages(spam?: true)
- end
- end
-
- [true, false, nil].each do |allow_possible_spam|
- context "when recaptcha_disabled flag is #{allow_possible_spam.inspect}" do
- before do
- stub_feature_flags(allow_possible_spam: allow_possible_spam) unless allow_possible_spam.nil?
- end
-
- it_behaves_like 'marked as spam'
- end
- end
- end
-
shared_examples 'snippet create data is tracked' do
let(:counter) { Gitlab::UsageDataCounters::SnippetCounter }
@@ -169,8 +128,8 @@ describe Snippets::CreateService do
expect { subject }.not_to change { Snippet.count }
end
- it 'returns the error' do
- expect(snippet.errors.full_messages).to include('Repository could not be created')
+ it 'returns a generic creation error' do
+ expect(snippet.errors[:repository]).to eq ['Error creating the snippet - Repository could not be created']
end
it 'does not return a snippet with an id' do
@@ -178,6 +137,14 @@ describe Snippets::CreateService do
end
end
+ context 'when repository creation fails with invalid file name' do
+ let(:extra_opts) { { file_name: 'invalid://file/name/here' } }
+
+ it 'returns an appropriate error' do
+ expect(snippet.errors[:repository]).to eq ['Error creating the snippet - Invalid file name']
+ end
+ end
+
context 'when the commit action fails' do
before do
allow_next_instance_of(SnippetRepository) do |instance|
@@ -209,11 +176,11 @@ describe Snippets::CreateService do
subject
end
- it 'returns the error' do
+ it 'returns a generic error' do
response = subject
expect(response).to be_error
- expect(response.payload[:snippet].errors.full_messages).to eq ['foobar']
+ expect(response.payload[:snippet].errors[:repository]).to eq ['Error creating the snippet']
end
end
@@ -228,36 +195,14 @@ describe Snippets::CreateService do
expect(snippet.repository_exists?).to be_falsey
end
end
-
- context 'when feature flag :version_snippets is disabled' do
- before do
- stub_feature_flags(version_snippets: false)
- end
-
- it 'does not create snippet repository' do
- expect do
- subject
- end.to change(Snippet, :count).by(1)
-
- expect(snippet.repository_exists?).to be_falsey
- end
-
- it 'does not try to commit files' do
- expect_next_instance_of(described_class) do |instance|
- expect(instance).not_to receive(:create_commit)
- end
-
- subject
- end
- end
end
- shared_examples 'after_save callback to store_mentions' do
+ shared_examples 'after_save callback to store_mentions' do |mentionable_class|
context 'when mentionable attributes change' do
let(:extra_opts) { { description: "Description with #{user.to_reference}" } }
it 'saves mentions' do
- expect_next_instance_of(Snippet) do |instance|
+ expect_next_instance_of(mentionable_class) do |instance|
expect(instance).to receive(:store_mentions!).and_call_original
end
expect(snippet.user_mentions.count).to eq 1
@@ -266,7 +211,7 @@ describe Snippets::CreateService do
context 'when mentionable attributes do not change' do
it 'does not call store_mentions' do
- expect_next_instance_of(Snippet) do |instance|
+ expect_next_instance_of(mentionable_class) do |instance|
expect(instance).not_to receive(:store_mentions!)
end
expect(snippet.user_mentions.count).to eq 0
@@ -277,7 +222,7 @@ describe Snippets::CreateService do
it 'does not call store_mentions' do
base_opts.delete(:title)
- expect_next_instance_of(Snippet) do |instance|
+ expect_next_instance_of(mentionable_class) do |instance|
expect(instance).not_to receive(:store_mentions!)
end
expect(snippet.valid?).to be false
@@ -294,11 +239,25 @@ describe Snippets::CreateService do
it_behaves_like 'a service that creates a snippet'
it_behaves_like 'public visibility level restrictions apply'
- it_behaves_like 'spam check is performed'
+ it_behaves_like 'snippets spam check is performed'
it_behaves_like 'snippet create data is tracked'
it_behaves_like 'an error service response when save fails'
it_behaves_like 'creates repository and files'
- it_behaves_like 'after_save callback to store_mentions'
+ it_behaves_like 'after_save callback to store_mentions', ProjectSnippet
+
+ context 'when uploaded files are passed to the service' do
+ let(:extra_opts) { { files: ['foo'] } }
+
+ it 'does not move uploaded files to the snippet' do
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:move_temporary_files).and_call_original
+ end
+
+ expect_any_instance_of(FileMover).not_to receive(:execute)
+
+ subject
+ end
+ end
end
context 'when PersonalSnippet' do
@@ -306,12 +265,55 @@ describe Snippets::CreateService do
it_behaves_like 'a service that creates a snippet'
it_behaves_like 'public visibility level restrictions apply'
- it_behaves_like 'spam check is performed'
+ it_behaves_like 'snippets spam check is performed'
it_behaves_like 'snippet create data is tracked'
it_behaves_like 'an error service response when save fails'
it_behaves_like 'creates repository and files'
- pending('See https://gitlab.com/gitlab-org/gitlab/issues/30742') do
- it_behaves_like 'after_save callback to store_mentions'
+ it_behaves_like 'after_save callback to store_mentions', PersonalSnippet
+
+ context 'when the snippet description contains files' do
+ include FileMoverHelpers
+
+ let(:title) { 'Title' }
+ let(:picture_secret) { SecureRandom.hex }
+ let(:text_secret) { SecureRandom.hex }
+ let(:picture_file) { "/-/system/user/#{creator.id}/#{picture_secret}/picture.jpg" }
+ let(:text_file) { "/-/system/user/#{creator.id}/#{text_secret}/text.txt" }
+ let(:files) { [picture_file, text_file] }
+ let(:description) do
+ "Description with picture: ![picture](/uploads#{picture_file}) and "\
+ "text: [text.txt](/uploads#{text_file})"
+ end
+
+ before do
+ allow(FileUtils).to receive(:mkdir_p)
+ allow(FileUtils).to receive(:move)
+ end
+
+ let(:extra_opts) { { description: description, title: title, files: files } }
+
+ it 'stores the snippet description correctly' do
+ stub_file_mover(text_file)
+ stub_file_mover(picture_file)
+
+ snippet = subject.payload[:snippet]
+
+ expected_description = "Description with picture: "\
+ "![picture](/uploads/-/system/personal_snippet/#{snippet.id}/#{picture_secret}/picture.jpg) and "\
+ "text: [text.txt](/uploads/-/system/personal_snippet/#{snippet.id}/#{text_secret}/text.txt)"
+
+ expect(snippet.description).to eq(expected_description)
+ end
+
+ context 'when there is a validation error' do
+ let(:title) { nil }
+
+ it 'does not move uploaded files to the snippet' do
+ expect_any_instance_of(described_class).not_to receive(:move_temporary_files)
+
+ subject
+ end
+ end
end
end
end
diff --git a/spec/services/snippets/update_service_spec.rb b/spec/services/snippets/update_service_spec.rb
index 05fb725c065..38747ae907f 100644
--- a/spec/services/snippets/update_service_spec.rb
+++ b/spec/services/snippets/update_service_spec.rb
@@ -7,7 +7,7 @@ describe Snippets::UpdateService do
let_it_be(:user) { create(:user) }
let_it_be(:admin) { create :user, admin: true }
let(:visibility_level) { Gitlab::VisibilityLevel::PRIVATE }
- let(:options) do
+ let(:base_opts) do
{
title: 'Test snippet',
file_name: 'snippet.rb',
@@ -15,6 +15,8 @@ describe Snippets::UpdateService do
visibility_level: visibility_level
}
end
+ let(:extra_opts) { {} }
+ let(:options) { base_opts.merge(extra_opts) }
let(:updater) { user }
let(:service) { Snippets::UpdateService.new(project, updater, options) }
@@ -85,7 +87,7 @@ describe Snippets::UpdateService do
end
context 'when update fails' do
- let(:options) { { title: '' } }
+ let(:extra_opts) { { title: '' } }
it 'does not increment count' do
expect { subject }.not_to change { counter.read(:update) }
@@ -112,25 +114,16 @@ describe Snippets::UpdateService do
expect(blob.data).to eq options[:content]
end
- context 'when the repository does not exist' do
- it 'does not try to commit file' do
- allow(snippet).to receive(:repository_exists?).and_return(false)
-
- expect(service).not_to receive(:create_commit)
-
- subject
- end
- end
-
- context 'when feature flag is disabled' do
+ context 'when the repository creation fails' do
before do
- stub_feature_flags(version_snippets: false)
+ allow(snippet).to receive(:repository_exists?).and_return(false)
end
- it 'does not create repository' do
- subject
+ it 'raise an error' do
+ response = subject
- expect(snippet.repository).not_to exist
+ expect(response).to be_error
+ expect(response.payload[:snippet].errors[:repository].to_sentence).to eq 'Error updating the snippet - Repository could not be created'
end
it 'does not try to commit file' do
@@ -205,14 +198,24 @@ describe Snippets::UpdateService do
end
end
- it 'rolls back any snippet modifications' do
- option_keys = options.stringify_keys.keys
- orig_attrs = snippet.attributes.select { |k, v| k.in?(option_keys) }
+ context 'with snippet modifications' do
+ let(:option_keys) { options.stringify_keys.keys }
- subject
+ it 'rolls back any snippet modifications' do
+ orig_attrs = snippet.attributes.select { |k, v| k.in?(option_keys) }
+
+ subject
+
+ persisted_attrs = snippet.reload.attributes.select { |k, v| k.in?(option_keys) }
+ expect(orig_attrs).to eq persisted_attrs
+ end
+
+ it 'keeps any snippet modifications' do
+ subject
- current_attrs = snippet.attributes.select { |k, v| k.in?(option_keys) }
- expect(orig_attrs).to eq current_attrs
+ instance_attrs = snippet.attributes.select { |k, v| k.in?(option_keys) }
+ expect(options.stringify_keys).to eq instance_attrs
+ end
end
end
@@ -270,6 +273,35 @@ describe Snippets::UpdateService do
end
end
+ shared_examples 'committable attributes' do
+ context 'when file_name is updated' do
+ let(:extra_opts) { { file_name: 'snippet.rb' } }
+
+ it 'commits to repository' do
+ expect(service).to receive(:create_commit)
+ expect(subject).to be_success
+ end
+ end
+
+ context 'when content is updated' do
+ let(:extra_opts) { { content: 'puts "hello world"' } }
+
+ it 'commits to repository' do
+ expect(service).to receive(:create_commit)
+ expect(subject).to be_success
+ end
+ end
+
+ context 'when content or file_name is not updated' do
+ let(:options) { { title: 'Test snippet' } }
+
+ it 'does not perform any commit' do
+ expect(service).not_to receive(:create_commit)
+ expect(subject).to be_success
+ end
+ end
+ end
+
context 'when Project Snippet' do
let_it_be(:project) { create(:project) }
let!(:snippet) { create(:project_snippet, :repository, author: user, project: project) }
@@ -283,6 +315,12 @@ describe Snippets::UpdateService do
it_behaves_like 'snippet update data is tracked'
it_behaves_like 'updates repository content'
it_behaves_like 'commit operation fails'
+ it_behaves_like 'committable attributes'
+ it_behaves_like 'snippets spam check is performed' do
+ before do
+ subject
+ end
+ end
context 'when snippet does not have a repository' do
let!(:snippet) { create(:project_snippet, author: user, project: project) }
@@ -301,6 +339,12 @@ describe Snippets::UpdateService do
it_behaves_like 'snippet update data is tracked'
it_behaves_like 'updates repository content'
it_behaves_like 'commit operation fails'
+ it_behaves_like 'committable attributes'
+ it_behaves_like 'snippets spam check is performed' do
+ before do
+ subject
+ end
+ end
context 'when snippet does not have a repository' do
let!(:snippet) { create(:personal_snippet, author: user, project: project) }
diff --git a/spec/services/spam/spam_action_service_spec.rb b/spec/services/spam/spam_action_service_spec.rb
new file mode 100644
index 00000000000..560833aba97
--- /dev/null
+++ b/spec/services/spam/spam_action_service_spec.rb
@@ -0,0 +1,215 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Spam::SpamActionService do
+ include_context 'includes Spam constants'
+
+ let(:fake_ip) { '1.2.3.4' }
+ let(:fake_user_agent) { 'fake-user-agent' }
+ let(:fake_referrer) { 'fake-http-referrer' }
+ let(:env) do
+ { 'action_dispatch.remote_ip' => fake_ip,
+ 'HTTP_USER_AGENT' => fake_user_agent,
+ 'HTTP_REFERRER' => fake_referrer }
+ end
+ let(:request) { double(:request, env: env) }
+
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:user) { create(:user) }
+ let(:issue) { create(:issue, project: project, author: user) }
+
+ before do
+ issue.spam = false
+ end
+
+ describe '#initialize' do
+ subject { described_class.new(spammable: issue, request: request) }
+
+ context 'when the request is nil' do
+ let(:request) { nil }
+
+ it 'assembles the options with information from the spammable' do
+ aggregate_failures do
+ expect(subject.options[:ip_address]).to eq(issue.ip_address)
+ expect(subject.options[:user_agent]).to eq(issue.user_agent)
+ expect(subject.options.key?(:referrer)).to be_falsey
+ end
+ end
+ end
+
+ context 'when the request is present' do
+ let(:request) { double(:request, env: env) }
+
+ it 'assembles the options with information from the spammable' do
+ aggregate_failures do
+ expect(subject.options[:ip_address]).to eq(fake_ip)
+ expect(subject.options[:user_agent]).to eq(fake_user_agent)
+ expect(subject.options[:referrer]).to eq(fake_referrer)
+ end
+ end
+ end
+ end
+
+ shared_examples 'only checks for spam if a request is provided' do
+ context 'when request is missing' do
+ subject { described_class.new(spammable: issue, request: nil) }
+
+ it "doesn't check as spam" do
+ subject
+
+ expect(issue).not_to be_spam
+ end
+ end
+
+ context 'when request exists' do
+ it 'creates a spam log' do
+ expect { subject }
+ .to log_spam(title: issue.title, description: issue.description, noteable_type: 'Issue')
+ end
+ end
+ end
+
+ describe '#execute' do
+ let(:request) { double(:request, env: env) }
+ let(:fake_verdict_service) { double(:spam_verdict_service) }
+ let(:allowlisted) { false }
+
+ let_it_be(:existing_spam_log) { create(:spam_log, user: user, recaptcha_verified: false) }
+
+ subject do
+ described_service = described_class.new(spammable: issue, request: request)
+ allow(described_service).to receive(:allowlisted?).and_return(allowlisted)
+ described_service.execute(user: user, api: nil, recaptcha_verified: recaptcha_verified, spam_log_id: existing_spam_log.id)
+ end
+
+ before do
+ allow(Spam::SpamVerdictService).to receive(:new).and_return(fake_verdict_service)
+ end
+
+ context 'when reCAPTCHA was already verified' do
+ let(:recaptcha_verified) { true }
+
+ it "doesn't check with the SpamVerdictService" do
+ aggregate_failures do
+ expect(SpamLog).to receive(:verify_recaptcha!)
+ expect(fake_verdict_service).not_to receive(:execute)
+ end
+
+ subject
+ end
+
+ it 'updates spam log' do
+ expect { subject }.to change { existing_spam_log.reload.recaptcha_verified }.from(false).to(true)
+ end
+ end
+
+ context 'when reCAPTCHA was not verified' do
+ let(:recaptcha_verified) { false }
+
+ context 'when spammable attributes have not changed' do
+ before do
+ issue.closed_at = Time.zone.now
+ end
+
+ it 'does not create a spam log' do
+ expect { subject }
+ .not_to change { SpamLog.count }
+ end
+ end
+
+ context 'when spammable attributes have changed' do
+ before do
+ issue.description = 'SPAM!'
+ end
+
+ context 'if allowlisted' do
+ let(:allowlisted) { true }
+
+ it 'does not perform spam check' do
+ expect(Spam::SpamVerdictService).not_to receive(:new)
+
+ subject
+ end
+ end
+
+ context 'when disallowed by the spam verdict service' do
+ before do
+ allow(fake_verdict_service).to receive(:execute).and_return(DISALLOW)
+ end
+
+ context 'when allow_possible_spam feature flag is false' do
+ before do
+ stub_feature_flags(allow_possible_spam: false)
+ end
+
+ it_behaves_like 'only checks for spam if a request is provided'
+
+ it 'marks as spam' do
+ subject
+
+ expect(issue).to be_spam
+ end
+ end
+
+ context 'when allow_possible_spam feature flag is true' do
+ it_behaves_like 'only checks for spam if a request is provided'
+
+ it 'does not mark as spam' do
+ subject
+
+ expect(issue).not_to be_spam
+ end
+ end
+ end
+
+ context 'when spam verdict service requires reCAPTCHA' do
+ before do
+ allow(fake_verdict_service).to receive(:execute).and_return(REQUIRE_RECAPTCHA)
+ end
+
+ context 'when allow_possible_spam feature flag is false' do
+ before do
+ stub_feature_flags(allow_possible_spam: false)
+ end
+
+ it_behaves_like 'only checks for spam if a request is provided'
+
+ it 'does not mark as spam' do
+ subject
+
+ expect(issue).not_to be_spam
+ end
+
+ it 'marks as needing reCAPTCHA' do
+ subject
+
+ expect(issue.needs_recaptcha?).to be_truthy
+ end
+ end
+
+ context 'when allow_possible_spam feature flag is true' do
+ it_behaves_like 'only checks for spam if a request is provided'
+
+ it 'does not mark as needing reCAPTCHA' do
+ subject
+
+ expect(issue.needs_recaptcha).to be_falsey
+ end
+ end
+ end
+
+ context 'when spam verdict service allows creation' do
+ before do
+ allow(fake_verdict_service).to receive(:execute).and_return(ALLOW)
+ end
+
+ it 'does not create a spam log' do
+ expect { subject }
+ .not_to change { SpamLog.count }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/spam/spam_check_service_spec.rb b/spec/services/spam/spam_check_service_spec.rb
deleted file mode 100644
index 3d0cb1447bd..00000000000
--- a/spec/services/spam/spam_check_service_spec.rb
+++ /dev/null
@@ -1,170 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe Spam::SpamCheckService do
- let(:fake_ip) { '1.2.3.4' }
- let(:fake_user_agent) { 'fake-user-agent' }
- let(:fake_referrer) { 'fake-http-referrer' }
- let(:env) do
- { 'action_dispatch.remote_ip' => fake_ip,
- 'HTTP_USER_AGENT' => fake_user_agent,
- 'HTTP_REFERRER' => fake_referrer }
- end
- let(:request) { double(:request, env: env) }
-
- let_it_be(:project) { create(:project, :public) }
- let_it_be(:user) { create(:user) }
- let_it_be(:issue) { create(:issue, project: project, author: user) }
-
- before do
- issue.spam = false
- end
-
- describe '#initialize' do
- subject { described_class.new(spammable: issue, request: request) }
-
- context 'when the request is nil' do
- let(:request) { nil }
-
- it 'assembles the options with information from the spammable' do
- aggregate_failures do
- expect(subject.options[:ip_address]).to eq(issue.ip_address)
- expect(subject.options[:user_agent]).to eq(issue.user_agent)
- expect(subject.options.key?(:referrer)).to be_falsey
- end
- end
- end
-
- context 'when the request is present' do
- let(:request) { double(:request, env: env) }
-
- it 'assembles the options with information from the spammable' do
- aggregate_failures do
- expect(subject.options[:ip_address]).to eq(fake_ip)
- expect(subject.options[:user_agent]).to eq(fake_user_agent)
- expect(subject.options[:referrer]).to eq(fake_referrer)
- end
- end
- end
- end
-
- shared_examples 'only checks for spam if a request is provided' do
- context 'when request is missing' do
- let(:request) { nil }
-
- it "doesn't check as spam" do
- subject
-
- expect(issue).not_to be_spam
- end
- end
-
- context 'when request exists' do
- it 'creates a spam log' do
- expect { subject }
- .to log_spam(title: issue.title, description: issue.description, noteable_type: 'Issue')
- end
- end
- end
-
- describe '#execute' do
- let(:request) { double(:request, env: env) }
-
- let_it_be(:existing_spam_log) { create(:spam_log, user: user, recaptcha_verified: false) }
-
- subject do
- described_service = described_class.new(spammable: issue, request: request)
- described_service.execute(user_id: user.id, api: nil, recaptcha_verified: recaptcha_verified, spam_log_id: existing_spam_log.id)
- end
-
- context 'when recaptcha was already verified' do
- let(:recaptcha_verified) { true }
-
- it "updates spam log and doesn't check Akismet" do
- aggregate_failures do
- expect(SpamLog).not_to receive(:create!)
- expect(an_instance_of(described_class)).not_to receive(:check)
- end
-
- subject
- end
-
- it 'updates spam log' do
- expect { subject }.to change { existing_spam_log.reload.recaptcha_verified }.from(false).to(true)
- end
- end
-
- context 'when recaptcha was not verified' do
- let(:recaptcha_verified) { false }
-
- context 'when spammable attributes have not changed' do
- before do
- issue.closed_at = Time.zone.now
-
- allow(Spam::AkismetService).to receive(:new).and_return(double(spam?: true))
- end
-
- it 'returns false' do
- expect(subject).to be_falsey
- end
-
- it 'does not create a spam log' do
- expect { subject }
- .not_to change { SpamLog.count }
- end
- end
-
- context 'when spammable attributes have changed' do
- before do
- issue.description = 'SPAM!'
- end
-
- context 'when indicated as spam by Akismet' do
- before do
- allow(Spam::AkismetService).to receive(:new).and_return(double(spam?: true))
- end
-
- context 'when allow_possible_spam feature flag is false' do
- before do
- stub_feature_flags(allow_possible_spam: false)
- end
-
- it_behaves_like 'only checks for spam if a request is provided'
-
- it 'marks as spam' do
- subject
-
- expect(issue).to be_spam
- end
- end
-
- context 'when allow_possible_spam feature flag is true' do
- it_behaves_like 'only checks for spam if a request is provided'
-
- it 'does not mark as spam' do
- subject
-
- expect(issue).not_to be_spam
- end
- end
- end
-
- context 'when not indicated as spam by Akismet' do
- before do
- allow(Spam::AkismetService).to receive(:new).and_return(double(spam?: false))
- end
-
- it 'returns false' do
- expect(subject).to be_falsey
- end
-
- it 'does not create a spam log' do
- expect { subject }
- .not_to change { SpamLog.count }
- end
- end
- end
- end
- end
-end
diff --git a/spec/services/spam/spam_verdict_service_spec.rb b/spec/services/spam/spam_verdict_service_spec.rb
new file mode 100644
index 00000000000..93460a5e7d7
--- /dev/null
+++ b/spec/services/spam/spam_verdict_service_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Spam::SpamVerdictService do
+ include_context 'includes Spam constants'
+
+ let(:fake_ip) { '1.2.3.4' }
+ let(:fake_user_agent) { 'fake-user-agent' }
+ let(:fake_referrer) { 'fake-http-referrer' }
+ let(:env) do
+ { 'action_dispatch.remote_ip' => fake_ip,
+ 'HTTP_USER_AGENT' => fake_user_agent,
+ 'HTTP_REFERRER' => fake_referrer }
+ end
+ let(:request) { double(:request, env: env) }
+
+ let(:check_for_spam) { true }
+ let(:issue) { build(:issue) }
+ let(:service) do
+ described_class.new(target: issue, request: request, options: {})
+ end
+
+ describe '#execute' do
+ subject { service.execute }
+
+ before do
+ allow_next_instance_of(Spam::AkismetService) do |service|
+ allow(service).to receive(:spam?).and_return(spam_verdict)
+ end
+ end
+
+ context 'if Akismet considers it spam' do
+ let(:spam_verdict) { true }
+
+ context 'if reCAPTCHA is enabled' do
+ before do
+ stub_application_setting(recaptcha_enabled: true)
+ end
+
+ it 'requires reCAPTCHA' do
+ expect(subject).to eq REQUIRE_RECAPTCHA
+ end
+ end
+
+ context 'if reCAPTCHA is not enabled' do
+ before do
+ stub_application_setting(recaptcha_enabled: false)
+ end
+
+ it 'disallows the change' do
+ expect(subject).to eq DISALLOW
+ end
+ end
+ end
+
+ context 'if Akismet does not consider it spam' do
+ let(:spam_verdict) { false }
+
+ it 'allows the change' do
+ expect(subject).to eq ALLOW
+ end
+ end
+ end
+end
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index 5b87ec022ae..66f9b5d092f 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -6,6 +6,7 @@ describe SystemNoteService do
include Gitlab::Routing
include RepoHelpers
include AssetsHelpers
+ include DesignManagementTestHelpers
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group) }
@@ -462,7 +463,8 @@ describe SystemNoteService do
describe "existing reference" do
before do
allow(JIRA::Resource::Remotelink).to receive(:all).and_return([])
- message = "[#{author.name}|http://localhost/#{author.username}] mentioned this issue in [a commit of #{project.full_path}|http://localhost/#{project.full_path}/-/commit/#{commit.id}]:\n'#{commit.title.chomp}'"
+ message = double('message')
+ allow(message).to receive(:include?) { true }
allow_next_instance_of(JIRA::Resource::Issue) do |instance|
allow(instance).to receive(:comments).and_return([OpenStruct.new(body: message)])
end
@@ -635,4 +637,28 @@ describe SystemNoteService do
described_class.auto_resolve_prometheus_alert(noteable, project, author)
end
end
+
+ describe '.design_version_added' do
+ let(:version) { create(:design_version) }
+
+ it 'calls DesignManagementService' do
+ expect_next_instance_of(SystemNotes::DesignManagementService) do |service|
+ expect(service).to receive(:design_version_added).with(version)
+ end
+
+ described_class.design_version_added(version)
+ end
+ end
+
+ describe '.design_discussion_added' do
+ let(:discussion_note) { create(:diff_note_on_design) }
+
+ it 'calls DesignManagementService' do
+ expect_next_instance_of(SystemNotes::DesignManagementService) do |service|
+ expect(service).to receive(:design_discussion_added).with(discussion_note)
+ end
+
+ described_class.design_discussion_added(discussion_note)
+ end
+ end
end
diff --git a/spec/services/system_notes/design_management_service_spec.rb b/spec/services/system_notes/design_management_service_spec.rb
new file mode 100644
index 00000000000..08511e62341
--- /dev/null
+++ b/spec/services/system_notes/design_management_service_spec.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe SystemNotes::DesignManagementService do
+ let(:project) { create(:project) }
+ let(:issue) { create(:issue, project: project) }
+
+ let(:instance) { described_class.new(noteable: instance_noteable, project: instance_project, author: instance_author) }
+
+ describe '#design_version_added' do
+ let(:instance_noteable) { version.issue }
+ let(:instance_project) { version.issue.project }
+ let(:instance_author) { version.author }
+
+ subject { instance.design_version_added(version) }
+
+ # default (valid) parameters:
+ let(:n_designs) { 3 }
+ let(:designs) { create_list(:design, n_designs, issue: issue) }
+ let(:user) { build(:user) }
+ let(:version) do
+ create(:design_version, issue: issue, designs: designs)
+ end
+
+ before do
+ # Avoid needing to call into gitaly
+ allow(version).to receive(:author).and_return(user)
+ end
+
+ context 'with one kind of event' do
+ before do
+ DesignManagement::Action
+ .where(design: designs).update_all(event: :modification)
+ end
+
+ it 'makes just one note' do
+ expect(subject).to contain_exactly(Note)
+ end
+
+ it 'adds a new system note' do
+ expect { subject }.to change { Note.system.count }.by(1)
+ end
+ end
+
+ context 'with a mixture of events' do
+ let(:n_designs) { DesignManagement::Action.events.size }
+
+ before do
+ designs.each_with_index do |design, i|
+ design.actions.update_all(event: i)
+ end
+ end
+
+ it 'makes one note for each kind of event' do
+ expect(subject).to have_attributes(size: n_designs)
+ end
+
+ it 'adds a system note for each kind of event' do
+ expect { subject }.to change { Note.system.count }.by(n_designs)
+ end
+ end
+
+ describe 'icons' do
+ where(:action) do
+ [
+ [:creation],
+ [:modification],
+ [:deletion]
+ ]
+ end
+
+ with_them do
+ before do
+ version.actions.update_all(event: action)
+ end
+
+ subject(:metadata) do
+ instance.design_version_added(version)
+ .first.system_note_metadata
+ end
+
+ it 'has a valid action' do
+ expect(::SystemNoteHelper::ICON_NAMES_BY_ACTION)
+ .to include(metadata.action)
+ end
+ end
+ end
+
+ context 'it succeeds' do
+ where(:action, :icon, :human_description) do
+ [
+ [:creation, 'designs_added', 'added'],
+ [:modification, 'designs_modified', 'updated'],
+ [:deletion, 'designs_removed', 'removed']
+ ]
+ end
+
+ with_them do
+ before do
+ version.actions.update_all(event: action)
+ end
+
+ let(:anchor_tag) { %r{ <a[^>]*>#{link}</a>} }
+ let(:href) { instance.send(:designs_path, { version: version.id }) }
+ let(:link) { "#{n_designs} designs" }
+
+ subject(:note) { instance.design_version_added(version).first }
+
+ it 'has the correct data' do
+ expect(note)
+ .to be_system
+ .and have_attributes(
+ system_note_metadata: have_attributes(action: icon),
+ note: include(human_description)
+ .and(include link)
+ .and(include href),
+ note_html: a_string_matching(anchor_tag)
+ )
+ end
+ end
+ end
+ end
+
+ describe '#design_discussion_added' do
+ let(:instance_noteable) { design.issue }
+ let(:instance_project) { design.issue.project }
+ let(:instance_author) { discussion_note.author }
+
+ subject { instance.design_discussion_added(discussion_note) }
+
+ let(:design) { create(:design, :with_file, issue: issue) }
+ let(:author) { create(:user) }
+ let(:discussion_note) do
+ create(:diff_note_on_design, noteable: design, author: author)
+ end
+ let(:action) { 'designs_discussion_added' }
+
+ it_behaves_like 'a system note' do
+ let(:noteable) { discussion_note.noteable.issue }
+ end
+
+ it 'adds a new system note' do
+ expect { subject }.to change { Note.system.count }.by(1)
+ end
+
+ it 'has the correct note text' do
+ href = instance.send(:designs_path,
+ { vueroute: design.filename, anchor: ActionView::RecordIdentifier.dom_id(discussion_note) }
+ )
+
+ expect(subject.note).to eq("started a discussion on [#{design.filename}](#{href})")
+ end
+ end
+end
diff --git a/spec/services/template_engines/liquid_service_spec.rb b/spec/services/template_engines/liquid_service_spec.rb
deleted file mode 100644
index 7c5262bc264..00000000000
--- a/spec/services/template_engines/liquid_service_spec.rb
+++ /dev/null
@@ -1,126 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe TemplateEngines::LiquidService do
- describe '#render' do
- let(:template) { 'up{env={{ci_environment_slug}}}' }
- let(:result) { subject }
-
- let_it_be(:slug) { 'env_slug' }
-
- let_it_be(:context) do
- {
- ci_environment_slug: slug,
- environment_filter: "container_name!=\"POD\",environment=\"#{slug}\""
- }
- end
-
- subject { described_class.new(template).render(context) }
-
- it 'with symbol keys in context it substitutes variables' do
- expect(result).to include("up{env=#{slug}")
- end
-
- context 'with multiple occurrences of variable in template' do
- let(:template) do
- 'up{env1={{ci_environment_slug}},env2={{ci_environment_slug}}}'
- end
-
- it 'substitutes variables' do
- expect(result).to eq("up{env1=#{slug},env2=#{slug}}")
- end
- end
-
- context 'with multiple variables in template' do
- let(:template) do
- 'up{env={{ci_environment_slug}},' \
- '{{environment_filter}}}'
- end
-
- it 'substitutes all variables' do
- expect(result).to eq(
- "up{env=#{slug}," \
- "container_name!=\"POD\",environment=\"#{slug}\"}"
- )
- end
- end
-
- context 'with unknown variables in template' do
- let(:template) { 'up{env={{env_slug}}}' }
-
- it 'does not substitute unknown variables' do
- expect(result).to eq("up{env=}")
- end
- end
-
- context 'with extra variables in context' do
- let(:template) { 'up{env={{ci_environment_slug}}}' }
-
- it 'substitutes variables' do
- # If context has only 1 key, there is no need for this spec.
- expect(context.count).to be > 1
- expect(result).to eq("up{env=#{slug}}")
- end
- end
-
- context 'with unknown and known variables in template' do
- let(:template) { 'up{env={{ci_environment_slug}},other_env={{env_slug}}}' }
-
- it 'substitutes known variables' do
- expect(result).to eq("up{env=#{slug},other_env=}")
- end
- end
-
- context 'Liquid errors' do
- shared_examples 'raises RenderError' do |message|
- it do
- expect { result }.to raise_error(described_class::RenderError, message)
- end
- end
-
- context 'when liquid raises error' do
- let(:template) { 'up{env={{ci_environment_slug}}' }
- let(:liquid_template) { Liquid::Template.new }
-
- before do
- allow(Liquid::Template).to receive(:parse).with(template).and_return(liquid_template)
- allow(liquid_template).to receive(:render!).and_raise(exception, message)
- end
-
- context 'raises Liquid::MemoryError' do
- let(:exception) { Liquid::MemoryError }
- let(:message) { 'Liquid error: Memory limits exceeded' }
-
- it_behaves_like 'raises RenderError', 'Memory limit exceeded while rendering template'
- end
-
- context 'raises Liquid::Error' do
- let(:exception) { Liquid::Error }
- let(:message) { 'Liquid error: Generic error message' }
-
- it_behaves_like 'raises RenderError', 'Error rendering query'
- end
- end
-
- context 'with template that is expensive to render' do
- let(:template) do
- '{% assign loop_count = 1000 %}'\
- '{% assign padStr = "0" %}'\
- '{% assign number_to_pad = "1" %}'\
- '{% assign strLength = number_to_pad | size %}'\
- '{% assign padLength = loop_count | minus: strLength %}'\
- '{% if padLength > 0 %}'\
- ' {% assign padded = number_to_pad %}'\
- ' {% for position in (1..padLength) %}'\
- ' {% assign padded = padded | prepend: padStr %}'\
- ' {% endfor %}'\
- ' {{ padded }}'\
- '{% endif %}'
- end
-
- it_behaves_like 'raises RenderError', 'Memory limit exceeded while rendering template'
- end
- end
- end
-end
diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb
index 9b92590cb63..4894cf12372 100644
--- a/spec/services/todo_service_spec.rb
+++ b/spec/services/todo_service_spec.rb
@@ -895,6 +895,36 @@ describe TodoService do
end
end
+ describe 'Designs' do
+ include DesignManagementTestHelpers
+
+ let(:issue) { create(:issue, project: project) }
+ let(:design) { create(:design, issue: issue) }
+
+ before do
+ enable_design_management
+
+ project.add_guest(author)
+ project.add_developer(john_doe)
+ end
+
+ let(:note) do
+ build(:diff_note_on_design,
+ noteable: design,
+ author: author,
+ note: "Hey #{john_doe.to_reference}")
+ end
+
+ it 'creates a todo for mentioned user on new diff note' do
+ service.new_note(note, author)
+
+ should_create_todo(user: john_doe,
+ target: design,
+ action: Todo::MENTIONED,
+ note: note)
+ end
+ end
+
describe '#update_note' do
let(:noteable) { create(:issue, project: project) }
let(:note) { create(:note, project: project, note: mentions, noteable: noteable) }
diff --git a/spec/services/update_merge_request_metrics_service_spec.rb b/spec/services/update_merge_request_metrics_service_spec.rb
index bb07dfa1a0e..1aaf5e712f9 100644
--- a/spec/services/update_merge_request_metrics_service_spec.rb
+++ b/spec/services/update_merge_request_metrics_service_spec.rb
@@ -9,7 +9,7 @@ describe MergeRequestMetricsService do
it 'updates metrics' do
user = create(:user)
service = described_class.new(metrics)
- event = double(Event, author_id: user.id, created_at: Time.now)
+ event = double(Event, author_id: user.id, created_at: Time.current)
service.merge(event)
@@ -22,7 +22,7 @@ describe MergeRequestMetricsService do
it 'updates metrics' do
user = create(:user)
service = described_class.new(metrics)
- event = double(Event, author_id: user.id, created_at: Time.now)
+ event = double(Event, author_id: user.id, created_at: Time.current)
service.close(event)
diff --git a/spec/services/user_project_access_changed_service_spec.rb b/spec/services/user_project_access_changed_service_spec.rb
index 902ed723e09..f27eeb74265 100644
--- a/spec/services/user_project_access_changed_service_spec.rb
+++ b/spec/services/user_project_access_changed_service_spec.rb
@@ -17,5 +17,14 @@ describe UserProjectAccessChangedService do
described_class.new([1, 2]).execute(blocking: false)
end
+
+ it 'permits low-priority operation' do
+ expect(AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker).to(
+ receive(:bulk_perform_in).with(described_class::DELAY, [[1], [2]])
+ )
+
+ described_class.new([1, 2]).execute(blocking: false,
+ priority: described_class::LOW_PRIORITY)
+ end
end
end
diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb
index 216d9170274..6e4b293286b 100644
--- a/spec/services/users/destroy_service_spec.rb
+++ b/spec/services/users/destroy_service_spec.rb
@@ -42,13 +42,11 @@ describe Users::DestroyService do
it 'calls the bulk snippet destroy service for the user personal snippets' do
repo1 = create(:personal_snippet, :repository, author: user).snippet_repository
- repo2 = create(:project_snippet, :repository, author: user).snippet_repository
- repo3 = create(:project_snippet, :repository, project: project, author: user).snippet_repository
+ repo2 = create(:project_snippet, :repository, project: project, author: user).snippet_repository
aggregate_failures do
expect(gitlab_shell.repository_exists?(repo1.shard_name, repo1.disk_path + '.git')).to be_truthy
expect(gitlab_shell.repository_exists?(repo2.shard_name, repo2.disk_path + '.git')).to be_truthy
- expect(gitlab_shell.repository_exists?(repo3.shard_name, repo3.disk_path + '.git')).to be_truthy
end
# Call made when destroying user personal projects
@@ -59,17 +57,23 @@ describe Users::DestroyService do
# project snippets where projects are not user personal
# ones
expect(Snippets::BulkDestroyService).to receive(:new)
- .with(admin, user.snippets).and_call_original
+ .with(admin, user.snippets.only_personal_snippets).and_call_original
service.execute(user)
aggregate_failures do
expect(gitlab_shell.repository_exists?(repo1.shard_name, repo1.disk_path + '.git')).to be_falsey
expect(gitlab_shell.repository_exists?(repo2.shard_name, repo2.disk_path + '.git')).to be_falsey
- expect(gitlab_shell.repository_exists?(repo3.shard_name, repo3.disk_path + '.git')).to be_falsey
end
end
+ it 'does not delete project snippets that the user is the author of' do
+ repo = create(:project_snippet, :repository, author: user).snippet_repository
+ service.execute(user)
+ expect(gitlab_shell.repository_exists?(repo.shard_name, repo.disk_path + '.git')).to be_truthy
+ expect(User.ghost.snippets).to include(repo.snippet)
+ end
+
context 'when an error is raised deleting snippets' do
it 'does not delete user' do
snippet = create(:personal_snippet, :repository, author: user)
diff --git a/spec/services/users/migrate_to_ghost_user_service_spec.rb b/spec/services/users/migrate_to_ghost_user_service_spec.rb
index 40206775aed..a7d7c16a66f 100644
--- a/spec/services/users/migrate_to_ghost_user_service_spec.rb
+++ b/spec/services/users/migrate_to_ghost_user_service_spec.rb
@@ -78,6 +78,12 @@ describe Users::MigrateToGhostUserService do
end
end
+ context 'snippets' do
+ include_examples "migrating a deleted user's associated records to the ghost user", Snippet do
+ let(:created_record) { create(:snippet, project: project, author: user) }
+ end
+ end
+
context "when record migration fails with a rollback exception" do
before do
expect_any_instance_of(ActiveRecord::Associations::CollectionProxy)
diff --git a/spec/services/verify_pages_domain_service_spec.rb b/spec/services/verify_pages_domain_service_spec.rb
index f2b3b44d223..3f08ae84c14 100644
--- a/spec/services/verify_pages_domain_service_spec.rb
+++ b/spec/services/verify_pages_domain_service_spec.rb
@@ -348,7 +348,7 @@ describe VerifyPagesDomainService do
end
it 'does not shorten any grace period' do
- grace = Time.now + 1.year
+ grace = Time.current + 1.year
domain.update!(enabled_until: grace)
disallow_resolver!
diff --git a/spec/services/wiki_pages/base_service_spec.rb b/spec/services/wiki_pages/base_service_spec.rb
index 4c44c195ac8..fede86a5192 100644
--- a/spec/services/wiki_pages/base_service_spec.rb
+++ b/spec/services/wiki_pages/base_service_spec.rb
@@ -10,7 +10,7 @@ describe WikiPages::BaseService do
counter = Gitlab::UsageDataCounters::WikiPageCounter
error = counter::UnknownEvent
- let(:subject) { bad_service_class.new(project, user, {}) }
+ let(:subject) { bad_service_class.new(container: project, current_user: user) }
context 'the class implements usage_counter_action incorrectly' do
let(:bad_service_class) do
diff --git a/spec/services/wiki_pages/create_service_spec.rb b/spec/services/wiki_pages/create_service_spec.rb
index d63d62e9492..2a17805110e 100644
--- a/spec/services/wiki_pages/create_service_spec.rb
+++ b/spec/services/wiki_pages/create_service_spec.rb
@@ -3,96 +3,5 @@
require 'spec_helper'
describe WikiPages::CreateService do
- let(:project) { create(:project, :wiki_repo) }
- let(:user) { create(:user) }
- let(:page_title) { 'Title' }
-
- let(:opts) do
- {
- title: page_title,
- content: 'Content for wiki page',
- format: 'markdown'
- }
- end
-
- subject(:service) { described_class.new(project, user, opts) }
-
- before do
- project.add_developer(user)
- end
-
- describe '#execute' do
- it 'creates wiki page with valid attributes' do
- page = service.execute
-
- expect(page).to be_valid
- expect(page.title).to eq(opts[:title])
- expect(page.content).to eq(opts[:content])
- expect(page.format).to eq(opts[:format].to_sym)
- end
-
- it 'executes webhooks' do
- expect(service).to receive(:execute_hooks).once.with(WikiPage)
-
- service.execute
- end
-
- it 'counts wiki page creation' do
- counter = Gitlab::UsageDataCounters::WikiPageCounter
-
- expect { service.execute }.to change { counter.read(:create) }.by 1
- end
-
- shared_examples 'correct event created' do
- it 'creates appropriate events' do
- expect { service.execute }.to change { Event.count }.by 1
-
- expect(Event.recent.first).to have_attributes(
- action: Event::CREATED,
- target: have_attributes(canonical_slug: page_title)
- )
- end
- end
-
- context 'the new page is at the top level' do
- let(:page_title) { 'root-level-page' }
-
- include_examples 'correct event created'
- end
-
- context 'the new page is in a subsection' do
- let(:page_title) { 'subsection/page' }
-
- include_examples 'correct event created'
- end
-
- context 'the feature is disabled' do
- before do
- stub_feature_flags(wiki_events: false)
- end
-
- it 'does not record the activity' do
- expect { service.execute }.not_to change(Event, :count)
- end
- end
-
- context 'when the options are bad' do
- let(:page_title) { '' }
-
- it 'does not count a creation event' do
- counter = Gitlab::UsageDataCounters::WikiPageCounter
-
- expect { service.execute }.not_to change { counter.read(:create) }
- end
-
- it 'does not record the activity' do
- expect { service.execute }.not_to change(Event, :count)
- end
-
- it 'reports the error' do
- expect(service.execute).to be_invalid
- .and have_attributes(errors: be_present)
- end
- end
- end
+ it_behaves_like 'WikiPages::CreateService#execute', :project
end
diff --git a/spec/services/wiki_pages/destroy_service_spec.rb b/spec/services/wiki_pages/destroy_service_spec.rb
index e205bedfdb9..b6fee1fd896 100644
--- a/spec/services/wiki_pages/destroy_service_spec.rb
+++ b/spec/services/wiki_pages/destroy_service_spec.rb
@@ -3,52 +3,5 @@
require 'spec_helper'
describe WikiPages::DestroyService do
- let(:project) { create(:project) }
- let(:user) { create(:user) }
- let(:page) { create(:wiki_page) }
-
- subject(:service) { described_class.new(project, user) }
-
- before do
- project.add_developer(user)
- end
-
- describe '#execute' do
- it 'executes webhooks' do
- expect(service).to receive(:execute_hooks).once.with(page)
-
- service.execute(page)
- end
-
- it 'increments the delete count' do
- counter = Gitlab::UsageDataCounters::WikiPageCounter
-
- expect { service.execute(page) }.to change { counter.read(:delete) }.by 1
- end
-
- it 'creates a new wiki page deletion event' do
- expect { service.execute(page) }.to change { Event.count }.by 1
-
- expect(Event.recent.first).to have_attributes(
- action: Event::DESTROYED,
- target: have_attributes(canonical_slug: page.slug)
- )
- end
-
- it 'does not increment the delete count if the deletion failed' do
- counter = Gitlab::UsageDataCounters::WikiPageCounter
-
- expect { service.execute(nil) }.not_to change { counter.read(:delete) }
- end
- end
-
- context 'the feature is disabled' do
- before do
- stub_feature_flags(wiki_events: false)
- end
-
- it 'does not record the activity' do
- expect { service.execute(page) }.not_to change(Event, :count)
- end
- end
+ it_behaves_like 'WikiPages::DestroyService#execute', :project
end
diff --git a/spec/services/wiki_pages/event_create_service_spec.rb b/spec/services/wiki_pages/event_create_service_spec.rb
new file mode 100644
index 00000000000..cf971b0a02c
--- /dev/null
+++ b/spec/services/wiki_pages/event_create_service_spec.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe WikiPages::EventCreateService do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ subject { described_class.new(user) }
+
+ describe '#execute' do
+ let_it_be(:page) { create(:wiki_page, project: project) }
+ let(:slug) { generate(:sluggified_title) }
+ let(:action) { Event::CREATED }
+ let(:response) { subject.execute(slug, page, action) }
+
+ context 'feature flag is not enabled' do
+ before do
+ stub_feature_flags(wiki_events: false)
+ end
+
+ it 'does not error' do
+ expect(response).to be_success
+ .and have_attributes(message: /No event created/)
+ end
+
+ it 'does not create an event' do
+ expect { response }.not_to change(Event, :count)
+ end
+ end
+
+ context 'the user is nil' do
+ subject { described_class.new(nil) }
+
+ it 'raises an error on construction' do
+ expect { subject }.to raise_error ArgumentError
+ end
+ end
+
+ context 'the action is illegal' do
+ let(:action) { Event::WIKI_ACTIONS.max + 1 }
+
+ it 'returns an error' do
+ expect(response).to be_error
+ end
+
+ it 'does not create an event' do
+ expect { response }.not_to change(Event, :count)
+ end
+
+ it 'does not create a metadata record' do
+ expect { response }.not_to change(WikiPage::Meta, :count)
+ end
+ end
+
+ it 'returns a successful response' do
+ expect(response).to be_success
+ end
+
+ context 'the action is a deletion' do
+ let(:action) { Event::DESTROYED }
+
+ it 'does not synchronize the wiki metadata timestamps with the git commit' do
+ expect_next_instance_of(WikiPage::Meta) do |instance|
+ expect(instance).not_to receive(:synch_times_with_page)
+ end
+
+ response
+ end
+ end
+
+ it 'creates a wiki page event' do
+ expect { response }.to change(Event, :count).by(1)
+ end
+
+ it 'returns an event in the payload' do
+ expect(response.payload).to include(event: have_attributes(author: user, wiki_page?: true, action: action))
+ end
+
+ it 'records the slug for the page' do
+ response
+ meta = WikiPage::Meta.find_or_create(page.slug, page)
+
+ expect(meta.slugs.pluck(:slug)).to include(slug)
+ end
+ end
+end
diff --git a/spec/services/wiki_pages/update_service_spec.rb b/spec/services/wiki_pages/update_service_spec.rb
index ece714ee8e5..ac629a96f9a 100644
--- a/spec/services/wiki_pages/update_service_spec.rb
+++ b/spec/services/wiki_pages/update_service_spec.rb
@@ -3,100 +3,5 @@
require 'spec_helper'
describe WikiPages::UpdateService do
- let(:project) { create(:project) }
- let(:user) { create(:user) }
- let(:page) { create(:wiki_page) }
- let(:page_title) { 'New Title' }
-
- let(:opts) do
- {
- content: 'New content for wiki page',
- format: 'markdown',
- message: 'New wiki message',
- title: page_title
- }
- end
-
- subject(:service) { described_class.new(project, user, opts) }
-
- before do
- project.add_developer(user)
- end
-
- describe '#execute' do
- it 'updates the wiki page' do
- updated_page = service.execute(page)
-
- expect(updated_page).to be_valid
- expect(updated_page.message).to eq(opts[:message])
- expect(updated_page.content).to eq(opts[:content])
- expect(updated_page.format).to eq(opts[:format].to_sym)
- expect(updated_page.title).to eq(page_title)
- end
-
- it 'executes webhooks' do
- expect(service).to receive(:execute_hooks).once.with(WikiPage)
-
- service.execute(page)
- end
-
- it 'counts edit events' do
- counter = Gitlab::UsageDataCounters::WikiPageCounter
-
- expect { service.execute page }.to change { counter.read(:update) }.by 1
- end
-
- shared_examples 'adds activity event' do
- it 'adds a new wiki page activity event' do
- expect { service.execute(page) }.to change { Event.count }.by 1
-
- expect(Event.recent.first).to have_attributes(
- action: Event::UPDATED,
- wiki_page: page,
- target_title: page.title
- )
- end
- end
-
- context 'the page is at the top level' do
- let(:page_title) { 'Top level page' }
-
- include_examples 'adds activity event'
- end
-
- context 'the page is in a subsection' do
- let(:page_title) { 'Subsection / secondary page' }
-
- include_examples 'adds activity event'
- end
-
- context 'the feature is disabled' do
- before do
- stub_feature_flags(wiki_events: false)
- end
-
- it 'does not record the activity' do
- expect { service.execute(page) }.not_to change(Event, :count)
- end
- end
-
- context 'when the options are bad' do
- let(:page_title) { '' }
-
- it 'does not count an edit event' do
- counter = Gitlab::UsageDataCounters::WikiPageCounter
-
- expect { service.execute page }.not_to change { counter.read(:update) }
- end
-
- it 'does not record the activity' do
- expect { service.execute page }.not_to change(Event, :count)
- end
-
- it 'reports the error' do
- expect(service.execute(page)).to be_invalid
- .and have_attributes(errors: be_present)
- end
- end
- end
+ it_behaves_like 'WikiPages::UpdateService#execute', :project
end
diff --git a/spec/services/wikis/create_attachment_service_spec.rb b/spec/services/wikis/create_attachment_service_spec.rb
index 7a73a0a555f..4adfaa24874 100644
--- a/spec/services/wikis/create_attachment_service_spec.rb
+++ b/spec/services/wikis/create_attachment_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
describe Wikis::CreateAttachmentService do
- let(:project) { create(:project, :wiki_repo) }
+ let(:container) { create(:project, :wiki_repo) }
let(:user) { create(:user) }
let(:file_name) { 'filename.txt' }
let(:file_path_regex) { %r{#{described_class::ATTACHMENT_PATH}/\h{32}/#{file_name}} }
@@ -15,25 +15,21 @@ describe Wikis::CreateAttachmentService do
end
let(:opts) { file_opts }
- subject(:service) { described_class.new(project, user, opts) }
+ subject(:service) { described_class.new(container: container, current_user: user, params: opts) }
before do
- project.add_developer(user)
+ container.add_developer(user)
end
describe 'initialization' do
context 'author commit info' do
it 'does not raise error if user is nil' do
- service = described_class.new(project, nil, opts)
+ service = described_class.new(container: container, current_user: nil, params: opts)
expect(service.instance_variable_get(:@author_email)).to be_nil
expect(service.instance_variable_get(:@author_name)).to be_nil
end
- it 'fills file_path from the repository uploads folder' do
- expect(service.instance_variable_get(:@file_path)).to match(file_path_regex)
- end
-
context 'when no author info provided' do
it 'fills author_email and author_name from current_user info' do
expect(service.instance_variable_get(:@author_email)).to eq user.email
@@ -73,7 +69,7 @@ describe Wikis::CreateAttachmentService do
context 'branch name' do
context 'when no branch provided' do
it 'sets the branch from the wiki default_branch' do
- expect(service.instance_variable_get(:@branch_name)).to eq project.wiki.default_branch
+ expect(service.instance_variable_get(:@branch_name)).to eq container.wiki.default_branch
end
end
@@ -151,7 +147,7 @@ describe Wikis::CreateAttachmentService do
context 'when user' do
shared_examples 'wiki attachment user validations' do
it 'returns error' do
- result = described_class.new(project, user2, opts).execute
+ result = described_class.new(container: container, current_user: user2, params: opts).execute
expect(result[:status]).to eq :error
expect(result[:message]).to eq 'You are not allowed to push to the wiki'
@@ -172,54 +168,5 @@ describe Wikis::CreateAttachmentService do
end
end
- describe '#execute' do
- let(:wiki) { project.wiki }
-
- subject(:service_execute) { service.execute[:result] }
-
- context 'creates branch if it does not exists' do
- let(:branch_name) { 'new_branch' }
- let(:opts) { file_opts.merge(branch_name: branch_name) }
-
- it do
- expect(wiki.repository.branches).to be_empty
- expect { service.execute }.to change { wiki.repository.branches.count }.by(1)
- expect(wiki.repository.branches.first.name).to eq branch_name
- end
- end
-
- it 'adds file to the repository' do
- expect(wiki.repository.ls_files('HEAD')).to be_empty
-
- service.execute
-
- files = wiki.repository.ls_files('HEAD')
- expect(files.count).to eq 1
- expect(files.first).to match(file_path_regex)
- end
-
- context 'returns' do
- before do
- allow(SecureRandom).to receive(:hex).and_return('fixed_hex')
-
- service_execute
- end
-
- it 'returns the file name' do
- expect(service_execute[:file_name]).to eq file_name
- end
-
- it 'returns the path where file was stored' do
- expect(service_execute[:file_path]).to eq 'uploads/fixed_hex/filename.txt'
- end
-
- it 'returns the branch where the file was pushed' do
- expect(service_execute[:branch]).to eq wiki.default_branch
- end
-
- it 'returns the commit id' do
- expect(service_execute[:commit]).not_to be_empty
- end
- end
- end
+ it_behaves_like 'Wikis::CreateAttachmentService#execute', :project
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index fe03621b9bf..80dfa20a2f1 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -8,10 +8,12 @@ ENV["IN_MEMORY_APPLICATION_SETTINGS"] = 'true'
ENV["RSPEC_ALLOW_INVALID_URLS"] = 'true'
require File.expand_path('../config/environment', __dir__)
+
+require 'rspec/mocks'
require 'rspec/rails'
-require 'shoulda/matchers'
require 'rspec/retry'
require 'rspec-parameterized'
+require 'shoulda/matchers'
require 'test_prof/recipes/rspec/let_it_be'
rspec_profiling_is_configured =
@@ -173,21 +175,19 @@ RSpec.configure do |config|
# Enable all features by default for testing
allow(Feature).to receive(:enabled?) { true }
- enabled = example.metadata[:enable_rugged].present?
+ enable_rugged = example.metadata[:enable_rugged].present?
# Disable Rugged features by default
Gitlab::Git::RuggedImpl::Repository::FEATURE_FLAGS.each do |flag|
- allow(Feature).to receive(:enabled?).with(flag).and_return(enabled)
+ stub_feature_flags(flag => enable_rugged)
end
- allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enabled)
+ allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged)
# The following can be removed when we remove the staged rollout strategy
# and we can just enable it using instance wide settings
# (ie. ApplicationSetting#auto_devops_enabled)
- allow(Feature).to receive(:enabled?)
- .with(:force_autodevops_on_by_default, anything)
- .and_return(false)
+ stub_feature_flags(force_autodevops_on_by_default: false)
# Enable Marginalia feature for all specs in the test suite.
allow(Gitlab::Marginalia).to receive(:cached_feature_enabled?).and_return(true)
@@ -196,11 +196,11 @@ RSpec.configure do |config|
# is feature-complete and can be made default in place
# of older sidebar.
# See https://gitlab.com/groups/gitlab-org/-/epics/1863
+ stub_feature_flags(vue_issuable_sidebar: false)
+ stub_feature_flags(vue_issuable_epic_sidebar: false)
+
allow(Feature).to receive(:enabled?)
- .with(:vue_issuable_sidebar, anything)
- .and_return(false)
- allow(Feature).to receive(:enabled?)
- .with(:vue_issuable_epic_sidebar, anything)
+ .with(/\Apromo_\w+\z/, default_enabled: false)
.and_return(false)
# Stub these calls due to being expensive operations
@@ -209,9 +209,7 @@ RSpec.configure do |config|
# expect(Gitlab::Git::KeepAround).to receive(:execute).and_call_original
allow(Gitlab::Git::KeepAround).to receive(:execute)
- [Gitlab::ThreadMemoryCache, Gitlab::ProcessMemoryCache].each do |cache|
- cache.cache_backend.clear
- end
+ Gitlab::ProcessMemoryCache.cache_backend.clear
Sidekiq::Worker.clear_all
@@ -235,26 +233,25 @@ RSpec.configure do |config|
./ee/spec/features
./ee/spec/finders
./ee/spec/lib
- ./ee/spec/models
- ./ee/spec/policies
./ee/spec/requests/admin
./ee/spec/serializers
./ee/spec/services
./ee/spec/support/protected_tags
- ./ee/spec/support/shared_examples
+ ./ee/spec/support/shared_examples/features
+ ./ee/spec/support/shared_examples/finders/geo
+ ./ee/spec/support/shared_examples/graphql/geo
+ ./ee/spec/support/shared_examples/services
./spec/features
./spec/finders
./spec/frontend
./spec/helpers
./spec/lib
- ./spec/models
- ./spec/policies
./spec/requests
./spec/serializers
./spec/services
- ./spec/support/cycle_analytics_helpers
./spec/support/protected_tags
- ./spec/support/shared_examples
+ ./spec/support/shared_examples/features
+ ./spec/support/shared_examples/requests
./spec/views
./spec/workers
)
@@ -286,12 +283,7 @@ RSpec.configure do |config|
end
config.around(:example, :request_store) do |example|
- RequestStore.begin!
-
- example.run
-
- RequestStore.end!
- RequestStore.clear!
+ Gitlab::WithRequestStore.with_request_store { example.run }
end
config.around do |example|
@@ -305,12 +297,10 @@ RSpec.configure do |config|
Gitlab::SidekiqMiddleware.server_configurator(
metrics: false, # The metrics don't go anywhere in tests
arguments_logger: false, # We're not logging the regular messages for inline jobs
- memory_killer: false, # This is not a thing we want to do inline in tests
- # Don't enable this if the request store is active in the spec itself
- # This needs to run within the `request_store` around block defined above
- request_store: !RequestStore.active?
+ memory_killer: false # This is not a thing we want to do inline in tests
).call(chain)
chain.add DisableQueryLimit
+ chain.insert_after ::Gitlab::SidekiqMiddleware::RequestStoreMiddleware, IsolatedRequestStore
example.run
end
diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb
index 90adfb1a2ee..38f9ccf23f5 100644
--- a/spec/support/capybara.rb
+++ b/spec/support/capybara.rb
@@ -7,7 +7,7 @@ require 'capybara-screenshot/rspec'
require 'selenium-webdriver'
# Give CI some extra time
-timeout = (ENV['CI'] || ENV['CI_SERVER']) ? 60 : 30
+timeout = ENV['CI'] || ENV['CI_SERVER'] ? 60 : 30
# Define an error class for JS console messages
JSConsoleError = Class.new(StandardError)
@@ -95,12 +95,23 @@ RSpec.configure do |config|
config.include CapybaraHelpers, type: :feature
config.before(:context, :js) do
+ # This prevents Selenium from creating thousands of connections while waiting for
+ # an element to appear
+ webmock_enable_with_http_connect_on_start!
+
next if $capybara_server_already_started
TestEnv.eager_load_driver_server
$capybara_server_already_started = true
end
+ config.after(:context, :js) do
+ # WebMock doesn't stub connections, so we need to restore the original behavior
+ # to prevent many specs from failing:
+ # https://github.com/bblimke/webmock/blob/master/README.md#connecting-on-nethttpstart
+ webmock_enable!
+ end
+
config.before(:example, :js) do
session = Capybara.current_session
diff --git a/spec/support/cycle_analytics_helpers/test_generation.rb b/spec/support/cycle_analytics_helpers/test_generation.rb
index 34018263339..c577e5cc665 100644
--- a/spec/support/cycle_analytics_helpers/test_generation.rb
+++ b/spec/support/cycle_analytics_helpers/test_generation.rb
@@ -29,6 +29,10 @@ module CycleAnalyticsHelpers
scenarios.each do |start_time_conditions, end_time_conditions|
let_it_be(:other_project) { create(:project, :repository) }
+ before do
+ other_project.add_developer(self.user)
+ end
+
context "start condition: #{start_time_conditions.map(&:first).to_sentence}" do
context "end condition: #{end_time_conditions.map(&:first).to_sentence}" do
it "finds the median of available durations between the two conditions", :sidekiq_might_not_need_inline do
diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb
index f6339d7343c..60d82f7e92a 100644
--- a/spec/support/database_cleaner.rb
+++ b/spec/support/database_cleaner.rb
@@ -35,6 +35,8 @@ RSpec.configure do |config|
puts "Recreating the database"
start = Gitlab::Metrics::System.monotonic_time
+ ActiveRecord::AdvisoryLockBase.clear_all_connections!
+
ActiveRecord::Tasks::DatabaseTasks.drop_current
ActiveRecord::Tasks::DatabaseTasks.create_current
ActiveRecord::Tasks::DatabaseTasks.load_schema_current
diff --git a/spec/support/helpers/admin_mode_helpers.rb b/spec/support/helpers/admin_mode_helpers.rb
index e995a7d4f5e..36ed262f8ae 100644
--- a/spec/support/helpers/admin_mode_helpers.rb
+++ b/spec/support/helpers/admin_mode_helpers.rb
@@ -7,6 +7,9 @@ module AdminModeHelper
# mode for accessing any administrative functionality. This helper lets a user
# be in admin mode without requiring a second authentication step (provided
# the user is an admin)
+ #
+ # See also tag :enable_admin_mode in spec/spec_helper.rb for a spec-wide
+ # alternative
def enable_admin_mode!(user)
fake_user_mode = instance_double(Gitlab::Auth::CurrentUserMode)
diff --git a/spec/support/helpers/concurrent_helpers.rb b/spec/support/helpers/concurrent_helpers.rb
new file mode 100644
index 00000000000..4eecc2133e7
--- /dev/null
+++ b/spec/support/helpers/concurrent_helpers.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module ConcurrentHelpers
+ Cancelled = Class.new(StandardError)
+
+ # To test for contention, we may need to run some actions in parallel. This
+ # helper takes an array of blocks and schedules them all on different threads
+ # in a fixed-size thread pool.
+ #
+ # @param [Array[Proc]] blocks
+ # @param [Integer] task_wait_time: time to wait for each task (upper bound on
+ # reasonable task execution time)
+ # @param [Integer] max_concurrency: maximum number of tasks to run at once
+ #
+ def run_parallel(blocks, task_wait_time: 20.seconds, max_concurrency: Concurrent.processor_count - 1)
+ thread_pool = Concurrent::FixedThreadPool.new(
+ [2, max_concurrency].max, { max_queue: blocks.size }
+ )
+ opts = { executor: thread_pool }
+
+ error = Concurrent::MVar.new
+
+ blocks.map { |block| Concurrent::Future.execute(opts, &block) }.each do |future|
+ future.wait(task_wait_time)
+
+ if future.complete?
+ error.put(future.reason) if future.reason && error.empty?
+ else
+ future.cancel
+ error.put(Cancelled.new) if error.empty?
+ end
+ end
+
+ raise error.take if error.full?
+ ensure
+ thread_pool.shutdown
+ thread_pool.wait_for_termination(10)
+ thread_pool.kill if thread_pool.running?
+ end
+end
diff --git a/spec/support/helpers/design_management_test_helpers.rb b/spec/support/helpers/design_management_test_helpers.rb
new file mode 100644
index 00000000000..bf41e2f5079
--- /dev/null
+++ b/spec/support/helpers/design_management_test_helpers.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module DesignManagementTestHelpers
+ def enable_design_management(enabled = true, ref_filter = true)
+ stub_lfs_setting(enabled: enabled)
+ stub_feature_flags(design_management_reference_filter_gfm_pipeline: ref_filter)
+ end
+
+ def delete_designs(*designs)
+ act_on_designs(designs) { ::DesignManagement::Action.deletion }
+ end
+
+ def restore_designs(*designs)
+ act_on_designs(designs) { ::DesignManagement::Action.creation }
+ end
+
+ def modify_designs(*designs)
+ act_on_designs(designs) { ::DesignManagement::Action.modification }
+ end
+
+ def path_for_design(design)
+ path_options = { vueroute: design.filename }
+ Gitlab::Routing.url_helpers.designs_project_issue_path(design.project, design.issue, path_options)
+ end
+
+ def url_for_design(design)
+ path_options = { vueroute: design.filename }
+ Gitlab::Routing.url_helpers.designs_project_issue_url(design.project, design.issue, path_options)
+ end
+
+ def url_for_designs(issue)
+ Gitlab::Routing.url_helpers.designs_project_issue_url(issue.project, issue)
+ end
+
+ private
+
+ def act_on_designs(designs, &block)
+ issue = designs.first.issue
+ version = build(:design_version, :empty, issue: issue).tap { |v| v.save(validate: false) }
+ designs.each do |d|
+ yield.create(design: d, version: version)
+ end
+ version
+ end
+end
diff --git a/spec/support/helpers/exclusive_lease_helpers.rb b/spec/support/helpers/exclusive_lease_helpers.rb
index 77703e20602..95cfc56c273 100644
--- a/spec/support/helpers/exclusive_lease_helpers.rb
+++ b/spec/support/helpers/exclusive_lease_helpers.rb
@@ -9,7 +9,9 @@ module ExclusiveLeaseHelpers
Gitlab::ExclusiveLease,
try_obtain: uuid,
exists?: true,
- renew: renew
+ renew: renew,
+ cancel: nil,
+ ttl: timeout
)
allow(Gitlab::ExclusiveLease)
diff --git a/spec/support/helpers/fake_blob_helpers.rb b/spec/support/helpers/fake_blob_helpers.rb
index a7eafb0fd23..6c8866deac4 100644
--- a/spec/support/helpers/fake_blob_helpers.rb
+++ b/spec/support/helpers/fake_blob_helpers.rb
@@ -22,7 +22,11 @@ module FakeBlobHelpers
alias_method :name, :path
def id
- 0
+ "00000000"
+ end
+
+ def commit_id
+ "11111111"
end
def binary_in_repo?
diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb
index fc543186b08..b3d7f7bcece 100644
--- a/spec/support/helpers/graphql_helpers.rb
+++ b/spec/support/helpers/graphql_helpers.rb
@@ -246,12 +246,19 @@ module GraphqlHelpers
# Raises an error if no data is found
def graphql_data
+ # Note that `json_response` is defined as `let(:json_response)` and
+ # therefore, in a spec with multiple queries, will only contain data
+ # from the _first_ query, not subsequent ones
json_response['data'] || (raise NoData, graphql_errors)
end
def graphql_data_at(*path)
+ graphql_dig_at(graphql_data, *path)
+ end
+
+ def graphql_dig_at(data, *path)
keys = path.map { |segment| GraphqlHelpers.fieldnamerize(segment) }
- graphql_data.dig(*keys)
+ data.dig(*keys)
end
def graphql_errors
diff --git a/spec/support/helpers/jira_service_helper.rb b/spec/support/helpers/jira_service_helper.rb
index c23a8d52c84..198bedfe3bc 100644
--- a/spec/support/helpers/jira_service_helper.rb
+++ b/spec/support/helpers/jira_service_helper.rb
@@ -78,6 +78,11 @@ module JiraServiceHelper
JIRA_API + "/issue/#{issue_id}"
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)
+ end
+
def stub_jira_urls(issue_id)
WebMock.stub_request(:get, jira_project_url)
WebMock.stub_request(:get, jira_api_comment_url(issue_id)).to_return(body: jira_issue_comments)
diff --git a/spec/support/helpers/kubernetes_helpers.rb b/spec/support/helpers/kubernetes_helpers.rb
index ca910e47695..8882f31e2f4 100644
--- a/spec/support/helpers/kubernetes_helpers.rb
+++ b/spec/support/helpers/kubernetes_helpers.rb
@@ -3,6 +3,8 @@
module KubernetesHelpers
include Gitlab::Kubernetes
+ NODE_NAME = "gke-cluster-applications-default-pool-49b7f225-v527"
+
def kube_response(body)
{ body: body.to_json }
end
@@ -11,6 +13,14 @@ module KubernetesHelpers
kube_response(kube_pods_body)
end
+ def nodes_response
+ kube_response(nodes_body)
+ end
+
+ def nodes_metrics_response
+ kube_response(nodes_metrics_body)
+ end
+
def kube_pod_response
kube_response(kube_pod)
end
@@ -34,6 +44,9 @@ module KubernetesHelpers
WebMock
.stub_request(:get, api_url + '/apis/rbac.authorization.k8s.io/v1')
.to_return(kube_response(kube_v1_rbac_authorization_discovery_body))
+ WebMock
+ .stub_request(:get, api_url + '/apis/metrics.k8s.io/v1beta1')
+ .to_return(kube_response(kube_metrics_v1beta1_discovery_body))
end
def stub_kubeclient_discover_istio(api_url)
@@ -76,6 +89,22 @@ module KubernetesHelpers
WebMock.stub_request(:get, pods_url).to_return(response || kube_pods_response)
end
+ def stub_kubeclient_nodes(api_url)
+ stub_kubeclient_discover_base(api_url)
+
+ nodes_url = api_url + "/api/v1/nodes"
+
+ WebMock.stub_request(:get, nodes_url).to_return(nodes_response)
+ end
+
+ def stub_kubeclient_nodes_and_nodes_metrics(api_url)
+ stub_kubeclient_nodes(api_url)
+
+ nodes_url = api_url + "/apis/metrics.k8s.io/v1beta1/nodes"
+
+ WebMock.stub_request(:get, nodes_url).to_return(nodes_metrics_response)
+ end
+
def stub_kubeclient_pods(namespace, status: nil)
stub_kubeclient_discover(service.api_url)
pods_url = service.api_url + "/api/v1/namespaces/#{namespace}/pods"
@@ -201,28 +230,8 @@ module KubernetesHelpers
.to_return(kube_response({}))
end
- def stub_kubeclient_get_cluster_role_binding_error(api_url, name, status: 404)
- WebMock.stub_request(:get, api_url + "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/#{name}")
- .to_return(status: [status, "Internal Server Error"])
- end
-
- def stub_kubeclient_create_cluster_role_binding(api_url)
- WebMock.stub_request(:post, api_url + '/apis/rbac.authorization.k8s.io/v1/clusterrolebindings')
- .to_return(kube_response({}))
- end
-
- def stub_kubeclient_get_role_binding(api_url, name, namespace: 'default')
- WebMock.stub_request(:get, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings/#{name}")
- .to_return(kube_response({}))
- end
-
- def stub_kubeclient_get_role_binding_error(api_url, name, namespace: 'default', status: 404)
- WebMock.stub_request(:get, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings/#{name}")
- .to_return(status: [status, "Internal Server Error"])
- end
-
- def stub_kubeclient_create_role_binding(api_url, namespace: 'default')
- WebMock.stub_request(:post, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings")
+ def stub_kubeclient_put_cluster_role_binding(api_url, name)
+ WebMock.stub_request(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/#{name}")
.to_return(kube_response({}))
end
@@ -274,6 +283,7 @@ module KubernetesHelpers
{
"kind" => "APIResourceList",
"resources" => [
+ { "name" => "nodes", "namespaced" => false, "kind" => "Node" },
{ "name" => "pods", "namespaced" => true, "kind" => "Pod" },
{ "name" => "deployments", "namespaced" => true, "kind" => "Deployment" },
{ "name" => "secrets", "namespaced" => true, "kind" => "Secret" },
@@ -334,6 +344,16 @@ module KubernetesHelpers
}
end
+ def kube_metrics_v1beta1_discovery_body
+ {
+ "kind" => "APIResourceList",
+ "resources" => [
+ { "name" => "nodes", "namespaced" => false, "kind" => "NodeMetrics" },
+ { "name" => "pods", "namespaced" => true, "kind" => "PodMetrics" }
+ ]
+ }
+ end
+
def kube_istio_discovery_body
{
"kind" => "APIResourceList",
@@ -462,6 +482,20 @@ module KubernetesHelpers
}
end
+ def nodes_body
+ {
+ "kind" => "NodeList",
+ "items" => [kube_node]
+ }
+ end
+
+ def nodes_metrics_body
+ {
+ "kind" => "List",
+ "items" => [kube_node_metrics]
+ }
+ end
+
def kube_logs_body
"2019-12-13T14:04:22.123456Z Log 1\n2019-12-13T14:04:23.123456Z Log 2\n2019-12-13T14:04:24.123456Z Log 3"
end
@@ -514,6 +548,40 @@ module KubernetesHelpers
}
end
+ # This is a partial response, it will have many more elements in reality but
+ # these are the ones we care about at the moment
+ def kube_node
+ {
+ "metadata" => {
+ "name" => NODE_NAME
+ },
+ "status" => {
+ "capacity" => {
+ "cpu" => "2",
+ "memory" => "7657228Ki"
+ },
+ "allocatable" => {
+ "cpu" => "1930m",
+ "memory" => "5777164Ki"
+ }
+ }
+ }
+ end
+
+ # This is a partial response, it will have many more elements in reality but
+ # these are the ones we care about at the moment
+ def kube_node_metrics
+ {
+ "metadata" => {
+ "name" => NODE_NAME
+ },
+ "usage" => {
+ "cpu" => "144208668n",
+ "memory" => "1789048Ki"
+ }
+ }
+ end
+
# Similar to a kube_pod, but should contain a running service
def kube_knative_pod(name: "kube-pod", namespace: "default", status: "Running")
{
diff --git a/spec/support/helpers/login_helpers.rb b/spec/support/helpers/login_helpers.rb
index 6a4dcfcdb1e..cb880939b1c 100644
--- a/spec/support/helpers/login_helpers.rb
+++ b/spec/support/helpers/login_helpers.rb
@@ -50,9 +50,7 @@ module LoginHelpers
def gitlab_enable_admin_mode_sign_in(user)
visit new_admin_session_path
-
fill_in 'user_password', with: user.password
-
click_button 'Enter Admin Mode'
end
diff --git a/spec/support/helpers/query_recorder.rb b/spec/support/helpers/query_recorder.rb
index fd200a1abf3..61634813a1c 100644
--- a/spec/support/helpers/query_recorder.rb
+++ b/spec/support/helpers/query_recorder.rb
@@ -19,7 +19,9 @@ module ActiveRecord
def show_backtrace(values)
Rails.logger.debug("QueryRecorder SQL: #{values[:sql]}")
- Gitlab::BacktraceCleaner.clean_backtrace(caller).each { |line| Rails.logger.debug(" --> #{line}") }
+ Gitlab::BacktraceCleaner.clean_backtrace(caller).each do |line|
+ Rails.logger.debug("QueryRecorder backtrace: --> #{line}")
+ end
end
def get_sql_source(sql)
diff --git a/spec/support/helpers/reactive_caching_helpers.rb b/spec/support/helpers/reactive_caching_helpers.rb
index aa9d3b3a199..0b0b0622696 100644
--- a/spec/support/helpers/reactive_caching_helpers.rb
+++ b/spec/support/helpers/reactive_caching_helpers.rb
@@ -10,8 +10,11 @@ module ReactiveCachingHelpers
end
def stub_reactive_cache(subject = nil, data = nil, *qualifiers)
- allow(ReactiveCachingWorker).to receive(:perform_async)
- allow(ReactiveCachingWorker).to receive(:perform_in)
+ ReactiveCaching::WORK_TYPE.values.each do |worker|
+ allow(worker).to receive(:perform_async)
+ allow(worker).to receive(:perform_in)
+ end
+
write_reactive_cache(subject, data, *qualifiers) unless subject.nil?
end
@@ -42,8 +45,8 @@ module ReactiveCachingHelpers
Rails.cache.write(alive_reactive_cache_key(subject, *qualifiers), true)
end
- def expect_reactive_cache_update_queued(subject)
- expect(ReactiveCachingWorker)
+ def expect_reactive_cache_update_queued(subject, worker_klass: ReactiveCachingWorker)
+ expect(worker_klass)
.to receive(:perform_in)
.with(subject.class.reactive_cache_refresh_interval, subject.class, subject.id)
end
diff --git a/spec/support/helpers/smime_helper.rb b/spec/support/helpers/smime_helper.rb
index 96da3d81708..261aef9518e 100644
--- a/spec/support/helpers/smime_helper.rb
+++ b/spec/support/helpers/smime_helper.rb
@@ -5,20 +5,24 @@ module SmimeHelper
SHORT_EXPIRY = 30.minutes
def generate_root
- issue(signed_by: nil, expires_in: INFINITE_EXPIRY, certificate_authority: true)
+ issue(cn: 'RootCA', signed_by: nil, expires_in: INFINITE_EXPIRY, certificate_authority: true)
end
- def generate_cert(root_ca:, expires_in: SHORT_EXPIRY)
- issue(signed_by: root_ca, expires_in: expires_in, certificate_authority: false)
+ def generate_intermediate(signer_ca:)
+ issue(cn: 'IntermediateCA', signed_by: signer_ca, expires_in: INFINITE_EXPIRY, certificate_authority: true)
+ end
+
+ def generate_cert(signer_ca:, expires_in: SHORT_EXPIRY)
+ issue(signed_by: signer_ca, expires_in: expires_in, certificate_authority: false)
end
# returns a hash { key:, cert: } containing a generated key, cert pair
- def issue(email_address: 'test@example.com', signed_by:, expires_in:, certificate_authority:)
+ def issue(email_address: 'test@example.com', cn: nil, signed_by:, expires_in:, certificate_authority:)
key = OpenSSL::PKey::RSA.new(4096)
public_key = key.public_key
subject = if certificate_authority
- OpenSSL::X509::Name.parse("/CN=EU")
+ OpenSSL::X509::Name.parse("/CN=#{cn}")
else
OpenSSL::X509::Name.parse("/CN=#{email_address}")
end
diff --git a/spec/support/helpers/stub_feature_flags.rb b/spec/support/helpers/stub_feature_flags.rb
index 6c3efff7262..5b8a85b206f 100644
--- a/spec/support/helpers/stub_feature_flags.rb
+++ b/spec/support/helpers/stub_feature_flags.rb
@@ -9,23 +9,27 @@ module StubFeatureFlags
# Examples
# - `stub_feature_flags(ci_live_trace: false)` ... Disable `ci_live_trace`
# feature flag globally.
- # - `stub_feature_flags(ci_live_trace: { enabled: false, thing: project })` ...
- # Disable `ci_live_trace` feature flag on the specified project.
+ # - `stub_feature_flags(ci_live_trace: project)` ...
+ # - `stub_feature_flags(ci_live_trace: [project1, project2])` ...
+ # Enable `ci_live_trace` feature flag only on the specified projects.
def stub_feature_flags(features)
- features.each do |feature_name, option|
- if option.is_a?(Hash)
- enabled, thing = option.values_at(:enabled, :thing)
- else
- enabled = option
- thing = nil
- end
+ features.each do |feature_name, actors|
+ allow(Feature).to receive(:enabled?).with(feature_name, any_args).and_return(false)
+ allow(Feature).to receive(:enabled?).with(feature_name.to_s, any_args).and_return(false)
+
+ Array(actors).each do |actor|
+ raise ArgumentError, "actor cannot be Hash" if actor.is_a?(Hash)
- if thing
- allow(Feature).to receive(:enabled?).with(feature_name, thing, any_args) { enabled }
- allow(Feature).to receive(:enabled?).with(feature_name.to_s, thing, any_args) { enabled }
- else
- allow(Feature).to receive(:enabled?).with(feature_name, any_args) { enabled }
- allow(Feature).to receive(:enabled?).with(feature_name.to_s, any_args) { enabled }
+ case actor
+ when false, true
+ allow(Feature).to receive(:enabled?).with(feature_name, any_args).and_return(actor)
+ allow(Feature).to receive(:enabled?).with(feature_name.to_s, any_args).and_return(actor)
+ when nil, ActiveRecord::Base, Symbol, RSpec::Mocks::Double
+ allow(Feature).to receive(:enabled?).with(feature_name, actor, any_args).and_return(true)
+ allow(Feature).to receive(:enabled?).with(feature_name.to_s, actor, any_args).and_return(true)
+ else
+ raise ArgumentError, "#stub_feature_flags accepts only `nil`, `true`, `false`, `ActiveRecord::Base` or `Symbol` as actors"
+ end
end
end
end
diff --git a/spec/support/helpers/stub_gitlab_calls.rb b/spec/support/helpers/stub_gitlab_calls.rb
index 40f4151c0fb..120d432655b 100644
--- a/spec/support/helpers/stub_gitlab_calls.rb
+++ b/spec/support/helpers/stub_gitlab_calls.rb
@@ -86,7 +86,7 @@ module StubGitlabCalls
def stub_container_registry_tag_manifest_content
fixture_path = 'spec/fixtures/container_registry/tag_manifest.json'
- JSON.parse(File.read(Rails.root + fixture_path))
+ Gitlab::Json.parse(File.read(Rails.root + fixture_path))
end
def stub_container_registry_blob_content
@@ -113,12 +113,12 @@ module StubGitlabCalls
def stub_project_8
data = File.read(Rails.root.join('spec/support/gitlab_stubs/project_8.json'))
- allow_any_instance_of(Network).to receive(:project).and_return(JSON.parse(data))
+ allow_any_instance_of(Network).to receive(:project).and_return(Gitlab::Json.parse(data))
end
def stub_project_8_hooks
data = File.read(Rails.root.join('spec/support/gitlab_stubs/project_8_hooks.json'))
- allow_any_instance_of(Network).to receive(:project_hooks).and_return(JSON.parse(data))
+ allow_any_instance_of(Network).to receive(:project_hooks).and_return(Gitlab::Json.parse(data))
end
def stub_projects
@@ -143,7 +143,7 @@ module StubGitlabCalls
def project_hash_array
f = File.read(Rails.root.join('spec/support/gitlab_stubs/projects.json'))
- JSON.parse f
+ Gitlab::Json.parse(f)
end
end
diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb
index d4ac286e959..b473cdaefc1 100644
--- a/spec/support/helpers/stub_object_storage.rb
+++ b/spec/support/helpers/stub_object_storage.rb
@@ -45,7 +45,7 @@ module StubObjectStorage
def stub_external_diffs_object_storage(uploader = described_class, **params)
stub_object_storage_uploader(config: Gitlab.config.external_diffs.object_store,
uploader: uploader,
- remote_directory: 'external_diffs',
+ remote_directory: 'external-diffs',
**params)
end
diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb
index 47d69ca1f6a..130650b7e2e 100644
--- a/spec/support/helpers/test_env.rb
+++ b/spec/support/helpers/test_env.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-require 'rspec/mocks'
-
module TestEnv
extend ActiveSupport::Concern
extend self
@@ -61,7 +59,7 @@ module TestEnv
'merge-commit-analyze-side-branch' => '8a99451',
'merge-commit-analyze-after' => '646ece5',
'snippet/single-file' => '43e4080aaa14fc7d4b77ee1f5c9d067d5a7df10e',
- 'snippet/multiple-files' => 'b80faa8c5b2b62f6489a0d84755580e927e1189b',
+ 'snippet/multiple-files' => '40232f7eb98b3f221886432def6e8bab2432add9',
'snippet/rename-and-edit-file' => '220a1e4b4dff37feea0625a7947a4c60fbe78365',
'snippet/edit-file' => 'c2f074f4f26929c92795a75775af79a6ed6d8430',
'snippet/no-files' => '671aaa842a4875e5f30082d1ab6feda345fdb94d',
@@ -284,29 +282,33 @@ module TestEnv
end
def setup_factory_repo
- setup_repo(factory_repo_path, factory_repo_path_bare, factory_repo_name,
- BRANCH_SHA)
+ setup_repo(factory_repo_path, factory_repo_path_bare, factory_repo_name, BRANCH_SHA)
end
# This repo has a submodule commit that is not present in the main test
# repository.
def setup_forked_repo
- setup_repo(forked_repo_path, forked_repo_path_bare, forked_repo_name,
- FORKED_BRANCH_SHA)
+ setup_repo(forked_repo_path, forked_repo_path_bare, forked_repo_name, FORKED_BRANCH_SHA)
end
def setup_repo(repo_path, repo_path_bare, repo_name, refs)
clone_url = "https://gitlab.com/gitlab-org/#{repo_name}.git"
unless File.directory?(repo_path)
- system(*%W(#{Gitlab.config.git.bin_path} clone -q #{clone_url} #{repo_path}))
+ puts "\n==> Setting up #{repo_name} repository in #{repo_path}..."
+ start = Time.now
+ system(*%W(#{Gitlab.config.git.bin_path} clone --quiet -- #{clone_url} #{repo_path}))
+ puts " #{repo_path} set up in #{Time.now - start} seconds...\n"
end
set_repo_refs(repo_path, refs)
unless File.directory?(repo_path_bare)
+ puts "\n==> Setting up #{repo_name} bare repository in #{repo_path_bare}..."
+ start = Time.now
# We must copy bare repositories because we will push to them.
- system(git_env, *%W(#{Gitlab.config.git.bin_path} clone -q --bare #{repo_path} #{repo_path_bare}))
+ system(git_env, *%W(#{Gitlab.config.git.bin_path} clone --quiet --bare -- #{repo_path} #{repo_path_bare}))
+ puts " #{repo_path_bare} set up in #{Time.now - start} seconds...\n"
end
end
diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb
index 1f1e686fb21..382e4f6a1a4 100644
--- a/spec/support/helpers/usage_data_helpers.rb
+++ b/spec/support/helpers/usage_data_helpers.rb
@@ -19,6 +19,9 @@ module UsageDataHelpers
cycle_analytics_views
productivity_analytics_views
source_code_pushes
+ design_management_designs_create
+ design_management_designs_update
+ design_management_designs_delete
).freeze
COUNTS_KEYS = %i(
@@ -96,6 +99,24 @@ module UsageDataHelpers
projects_with_error_tracking_enabled
projects_with_alerts_service_enabled
projects_with_prometheus_alerts
+ projects_with_expiration_policy_enabled
+ projects_with_expiration_policy_disabled
+ projects_with_expiration_policy_enabled_with_keep_n_unset
+ projects_with_expiration_policy_enabled_with_keep_n_set_to_1
+ projects_with_expiration_policy_enabled_with_keep_n_set_to_5
+ projects_with_expiration_policy_enabled_with_keep_n_set_to_10
+ projects_with_expiration_policy_enabled_with_keep_n_set_to_25
+ projects_with_expiration_policy_enabled_with_keep_n_set_to_50
+ projects_with_expiration_policy_enabled_with_older_than_unset
+ projects_with_expiration_policy_enabled_with_older_than_set_to_7d
+ projects_with_expiration_policy_enabled_with_older_than_set_to_14d
+ projects_with_expiration_policy_enabled_with_older_than_set_to_30d
+ projects_with_expiration_policy_enabled_with_older_than_set_to_90d
+ projects_with_expiration_policy_enabled_with_cadence_set_to_1d
+ projects_with_expiration_policy_enabled_with_cadence_set_to_7d
+ projects_with_expiration_policy_enabled_with_cadence_set_to_14d
+ projects_with_expiration_policy_enabled_with_cadence_set_to_1month
+ projects_with_expiration_policy_enabled_with_cadence_set_to_3month
pages_domains
protected_branches
releases
@@ -130,28 +151,62 @@ module UsageDataHelpers
gitaly
database
avg_cycle_analytics
- influxdb_metrics_enabled
prometheus_metrics_enabled
web_ide_clientside_preview_enabled
ingress_modsecurity_enabled
- projects_with_expiration_policy_disabled
- projects_with_expiration_policy_enabled
- projects_with_expiration_policy_enabled_with_keep_n_unset
- projects_with_expiration_policy_enabled_with_older_than_unset
- projects_with_expiration_policy_enabled_with_keep_n_set_to_1
- projects_with_expiration_policy_enabled_with_keep_n_set_to_5
- projects_with_expiration_policy_enabled_with_keep_n_set_to_10
- projects_with_expiration_policy_enabled_with_keep_n_set_to_25
- projects_with_expiration_policy_enabled_with_keep_n_set_to_50
- projects_with_expiration_policy_enabled_with_keep_n_set_to_100
- projects_with_expiration_policy_enabled_with_cadence_set_to_1d
- projects_with_expiration_policy_enabled_with_cadence_set_to_7d
- projects_with_expiration_policy_enabled_with_cadence_set_to_14d
- projects_with_expiration_policy_enabled_with_cadence_set_to_1month
- projects_with_expiration_policy_enabled_with_cadence_set_to_3month
- projects_with_expiration_policy_enabled_with_older_than_set_to_7d
- projects_with_expiration_policy_enabled_with_older_than_set_to_14d
- projects_with_expiration_policy_enabled_with_older_than_set_to_30d
- projects_with_expiration_policy_enabled_with_older_than_set_to_90d
+ object_store
).freeze
+
+ def stub_object_store_settings
+ allow(Settings).to receive(:[]).with('artifacts')
+ .and_return(
+ { 'enabled' => true,
+ 'object_store' =>
+ { 'enabled' => true,
+ 'remote_directory' => 'artifacts',
+ 'direct_upload' => true,
+ 'connection' =>
+ { 'provider' => 'AWS', 'aws_access_key_id' => 'minio', 'aws_secret_access_key' => 'gdk-minio', 'region' => 'gdk', 'endpoint' => 'http://127.0.0.1:9000', 'path_style' => true },
+ 'background_upload' => false,
+ 'proxy_download' => false } }
+ )
+
+ allow(Settings).to receive(:[]).with('external_diffs').and_return({ 'enabled' => false })
+
+ allow(Settings).to receive(:[]).with('lfs')
+ .and_return(
+ { 'enabled' => true,
+ 'object_store' =>
+ { 'enabled' => false,
+ 'remote_directory' => 'lfs-objects',
+ 'direct_upload' => true,
+ 'connection' =>
+ { 'provider' => 'AWS', 'aws_access_key_id' => 'minio', 'aws_secret_access_key' => 'gdk-minio', 'region' => 'gdk', 'endpoint' => 'http://127.0.0.1:9000', 'path_style' => true },
+ 'background_upload' => false,
+ 'proxy_download' => false } }
+ )
+ allow(Settings).to receive(:[]).with('uploads')
+ .and_return(
+ { 'object_store' =>
+ { 'enabled' => false,
+ 'remote_directory' => 'uploads',
+ 'direct_upload' => true,
+ 'connection' =>
+ { 'provider' => 'AWS', 'aws_access_key_id' => 'minio', 'aws_secret_access_key' => 'gdk-minio', 'region' => 'gdk', 'endpoint' => 'http://127.0.0.1:9000', 'path_style' => true },
+ 'background_upload' => false,
+ 'proxy_download' => false } }
+ )
+ allow(Settings).to receive(:[]).with('packages')
+ .and_return(
+ { 'enabled' => true,
+ 'object_store' =>
+ { 'enabled' => false,
+ 'remote_directory' => 'packages',
+ 'direct_upload' => false,
+ 'connection' =>
+ { 'provider' => 'AWS', 'aws_access_key_id' => 'minio', 'aws_secret_access_key' => 'gdk-minio', 'region' => 'gdk', 'endpoint' => 'http://127.0.0.1:9000', 'path_style' => true },
+ 'background_upload' => true,
+ 'proxy_download' => false } }
+ )
+ end
end
diff --git a/spec/support/helpers/wiki_helpers.rb b/spec/support/helpers/wiki_helpers.rb
index 86eb1793707..e6818ff8f0c 100644
--- a/spec/support/helpers/wiki_helpers.rb
+++ b/spec/support/helpers/wiki_helpers.rb
@@ -14,7 +14,10 @@ module WikiHelpers
file_content: File.read(expand_fixture_path(file_name))
}
- ::Wikis::CreateAttachmentService.new(project, user, opts)
- .execute[:result][:file_path]
+ ::Wikis::CreateAttachmentService.new(
+ container: project,
+ current_user: user,
+ params: opts
+ ).execute[:result][:file_path]
end
end
diff --git a/spec/support/helpers/workhorse_helpers.rb b/spec/support/helpers/workhorse_helpers.rb
index 53b36b3dd45..f16b6c1e910 100644
--- a/spec/support/helpers/workhorse_helpers.rb
+++ b/spec/support/helpers/workhorse_helpers.rb
@@ -11,7 +11,7 @@ module WorkhorseHelpers
header = split_header.join(':')
[
type,
- JSON.parse(Base64.urlsafe_decode64(header))
+ Gitlab::Json.parse(Base64.urlsafe_decode64(header))
]
end
end
diff --git a/spec/support/helpers/x509_helpers.rb b/spec/support/helpers/x509_helpers.rb
index 9ea997bf5f4..ce0fa268ace 100644
--- a/spec/support/helpers/x509_helpers.rb
+++ b/spec/support/helpers/x509_helpers.rb
@@ -173,22 +173,155 @@ module X509Helpers
Time.at(1561027326)
end
+ def signed_tag_signature
+ <<~SIGNATURE
+ -----BEGIN SIGNED MESSAGE-----
+ MIISfwYJKoZIhvcNAQcCoIIScDCCEmwCAQExDTALBglghkgBZQMEAgEwCwYJKoZI
+ hvcNAQcBoIIP8zCCB3QwggVcoAMCAQICBBXXLOIwDQYJKoZIhvcNAQELBQAwgbYx
+ CzAJBgNVBAYTAkRFMQ8wDQYDVQQIDAZCYXllcm4xETAPBgNVBAcMCE11ZW5jaGVu
+ MRAwDgYDVQQKDAdTaWVtZW5zMREwDwYDVQQFEwhaWlpaWlpBNjEdMBsGA1UECwwU
+ U2llbWVucyBUcnVzdCBDZW50ZXIxPzA9BgNVBAMMNlNpZW1lbnMgSXNzdWluZyBD
+ QSBNZWRpdW0gU3RyZW5ndGggQXV0aGVudGljYXRpb24gMjAxNjAeFw0xNzAyMDMw
+ NjU4MzNaFw0yMDAyMDMwNjU4MzNaMFsxETAPBgNVBAUTCFowMDBOV0RIMQ4wDAYD
+ VQQqDAVSb2dlcjEOMAwGA1UEBAwFTWVpZXIxEDAOBgNVBAoMB1NpZW1lbnMxFDAS
+ BgNVBAMMC01laWVyIFJvZ2VyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
+ AQEAuBNea/68ZCnHYQjpm/k3ZBG0wBpEKSwG6lk9CEQlSxsqVLQHAoAKBIlJm1in
+ YVLcK/Sq1yhYJ/qWcY/M53DhK2rpPuhtrWJUdOUy8EBWO20F4bd4Fw9pO7jt8bme
+ u33TSrK772vKjuppzB6SeG13Cs08H+BIeD106G27h7ufsO00pvsxoSDL+uc4slnr
+ pBL+2TAL7nSFnB9QHWmRIK27SPqJE+lESdb0pse11x1wjvqKy2Q7EjL9fpqJdHzX
+ NLKHXd2r024TOORTa05DFTNR+kQEKKV96XfpYdtSBomXNQ44cisiPBJjFtYvfnFE
+ wgrHa8fogn/b0C+A+HAoICN12wIDAQABo4IC4jCCAt4wHQYDVR0OBBYEFCF+gkUp
+ XQ6xGc0kRWXuDFxzA14zMEMGA1UdEQQ8MDqgIwYKKwYBBAGCNxQCA6AVDBNyLm1l
+ aWVyQHNpZW1lbnMuY29tgRNyLm1laWVyQHNpZW1lbnMuY29tMA4GA1UdDwEB/wQE
+ AwIHgDAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQwgcoGA1UdHwSBwjCB
+ vzCBvKCBuaCBtoYmaHR0cDovL2NoLnNpZW1lbnMuY29tL3BraT9aWlpaWlpBNi5j
+ cmyGQWxkYXA6Ly9jbC5zaWVtZW5zLm5ldC9DTj1aWlpaWlpBNixMPVBLST9jZXJ0
+ aWZpY2F0ZVJldm9jYXRpb25MaXN0hklsZGFwOi8vY2wuc2llbWVucy5jb20vQ049
+ WlpaWlpaQTYsbz1UcnVzdGNlbnRlcj9jZXJ0aWZpY2F0ZVJldm9jYXRpb25MaXN0
+ MEUGA1UdIAQ+MDwwOgYNKwYBBAGhaQcCAgMBAzApMCcGCCsGAQUFBwIBFhtodHRw
+ Oi8vd3d3LnNpZW1lbnMuY29tL3BraS8wDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAW
+ gBT4FV1HDGx3e3LEAheRaKK292oJRDCCAQQGCCsGAQUFBwEBBIH3MIH0MDIGCCsG
+ AQUFBzAChiZodHRwOi8vYWguc2llbWVucy5jb20vcGtpP1paWlpaWkE2LmNydDBB
+ BggrBgEFBQcwAoY1bGRhcDovL2FsLnNpZW1lbnMubmV0L0NOPVpaWlpaWkE2LEw9
+ UEtJP2NBQ2VydGlmaWNhdGUwSQYIKwYBBQUHMAKGPWxkYXA6Ly9hbC5zaWVtZW5z
+ LmNvbS9DTj1aWlpaWlpBNixvPVRydXN0Y2VudGVyP2NBQ2VydGlmaWNhdGUwMAYI
+ KwYBBQUHMAGGJGh0dHA6Ly9vY3NwLnBraS1zZXJ2aWNlcy5zaWVtZW5zLmNvbTAN
+ BgkqhkiG9w0BAQsFAAOCAgEAXPVcX6vaEcszJqg5IemF9aFTlwTrX5ITNIpzcqG+
+ kD5haOf2mZYLjl+MKtLC1XfmIsGCUZNb8bjP6QHQEI+2d6x/ZOqPq7Kd7PwVu6x6
+ xZrkDjUyhUbUntT5+RBy++l3Wf6Cq6Kx+K8ambHBP/bu90/p2U8KfFAG3Kr2gI2q
+ fZrnNMOxmJfZ3/sXxssgLkhbZ7hRa+MpLfQ6uFsSiat3vlawBBvTyHnoZ/7oRc8y
+ qi6QzWcd76CPpMElYWibl+hJzKbBZUWvc71AzHR6i1QeZ6wubYz7vr+FF5Y7tnxB
+ Vz6omPC9XAg0F+Dla6Zlz3Awj5imCzVXa+9SjtnsidmJdLcKzTAKyDewewoxYOOJ
+ j3cJU7VSjJPl+2fVmDBaQwcNcUcu/TPAKApkegqO7tRF9IPhjhW8QkRnkqMetO3D
+ OXmAFVIsEI0Hvb2cdb7B6jSpjGUuhaFm9TCKhQtCk2p8JCDTuaENLm1x34rrJKbT
+ 2vzyYN0CZtSkUdgD4yQxK9VWXGEzexRisWb4AnZjD2NAquLPpXmw8N0UwFD7MSpC
+ dpaX7FktdvZmMXsnGiAdtLSbBgLVWOD1gmJFDjrhNbI8NOaOaNk4jrfGqNh5lhGU
+ 4DnBT2U6Cie1anLmFH/oZooAEXR2o3Nu+1mNDJChnJp0ovs08aa3zZvBdcloOvfU
+ qdowggh3MIIGX6ADAgECAgQtyi/nMA0GCSqGSIb3DQEBCwUAMIGZMQswCQYDVQQG
+ EwJERTEPMA0GA1UECAwGQmF5ZXJuMREwDwYDVQQHDAhNdWVuY2hlbjEQMA4GA1UE
+ CgwHU2llbWVuczERMA8GA1UEBRMIWlpaWlpaQTExHTAbBgNVBAsMFFNpZW1lbnMg
+ VHJ1c3QgQ2VudGVyMSIwIAYDVQQDDBlTaWVtZW5zIFJvb3QgQ0EgVjMuMCAyMDE2
+ MB4XDTE2MDcyMDEzNDYxMFoXDTIyMDcyMDEzNDYxMFowgbYxCzAJBgNVBAYTAkRF
+ MQ8wDQYDVQQIDAZCYXllcm4xETAPBgNVBAcMCE11ZW5jaGVuMRAwDgYDVQQKDAdT
+ aWVtZW5zMREwDwYDVQQFEwhaWlpaWlpBNjEdMBsGA1UECwwUU2llbWVucyBUcnVz
+ dCBDZW50ZXIxPzA9BgNVBAMMNlNpZW1lbnMgSXNzdWluZyBDQSBNZWRpdW0gU3Ry
+ ZW5ndGggQXV0aGVudGljYXRpb24gMjAxNjCCAiIwDQYJKoZIhvcNAQEBBQADggIP
+ ADCCAgoCggIBAL9UfK+JAZEqVMVvECdYF9IK4KSw34AqyNl3rYP5x03dtmKaNu+2
+ 0fQqNESA1NGzw3s6LmrKLh1cR991nB2cvKOXu7AvEGpSuxzIcOROd4NpvRx+Ej1p
+ JIPeqf+ScmVK7lMSO8QL/QzjHOpGV3is9sG+ZIxOW9U1ESooy4Hal6ZNs4DNItsz
+ piCKqm6G3et4r2WqCy2RRuSqvnmMza7Y8BZsLy0ZVo5teObQ37E/FxqSrbDI8nxn
+ B7nVUve5ZjrqoIGSkEOtyo11003dVO1vmWB9A0WQGDqE/q3w178hGhKfxzRaqzyi
+ SoADUYS2sD/CglGTUxVq6u0pGLLsCFjItcCWqW+T9fPYfJ2CEd5b3hvqdCn+pXjZ
+ /gdX1XAcdUF5lRnGWifaYpT9n4s4adzX8q6oHSJxTppuAwLRKH6eXALbGQ1I9lGQ
+ DSOipD/09xkEsPw6HOepmf2U3YxZK1VU2sHqugFJboeLcHMzp6E1n2ctlNG1GKE9
+ FDHmdyFzDi0Nnxtf/GgVjnHF68hByEE1MYdJ4nJLuxoT9hyjYdRW9MpeNNxxZnmz
+ W3zh7QxIqP0ZfIz6XVhzrI9uZiqwwojDiM5tEOUkQ7XyW6grNXe75yt6mTj89LlB
+ H5fOW2RNmCy/jzBXDjgyskgK7kuCvUYTuRv8ITXbBY5axFA+CpxZqokpAgMBAAGj
+ ggKmMIICojCCAQUGCCsGAQUFBwEBBIH4MIH1MEEGCCsGAQUFBzAChjVsZGFwOi8v
+ YWwuc2llbWVucy5uZXQvQ049WlpaWlpaQTEsTD1QS0k/Y0FDZXJ0aWZpY2F0ZTAy
+ BggrBgEFBQcwAoYmaHR0cDovL2FoLnNpZW1lbnMuY29tL3BraT9aWlpaWlpBMS5j
+ cnQwSgYIKwYBBQUHMAKGPmxkYXA6Ly9hbC5zaWVtZW5zLmNvbS91aWQ9WlpaWlpa
+ QTEsbz1UcnVzdGNlbnRlcj9jQUNlcnRpZmljYXRlMDAGCCsGAQUFBzABhiRodHRw
+ Oi8vb2NzcC5wa2ktc2VydmljZXMuc2llbWVucy5jb20wHwYDVR0jBBgwFoAUcG2g
+ UOyp0CxnnRkV/v0EczXD4tQwEgYDVR0TAQH/BAgwBgEB/wIBADBABgNVHSAEOTA3
+ MDUGCCsGAQQBoWkHMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuc2llbWVucy5j
+ b20vcGtpLzCBxwYDVR0fBIG/MIG8MIG5oIG2oIGzhj9sZGFwOi8vY2wuc2llbWVu
+ cy5uZXQvQ049WlpaWlpaQTEsTD1QS0k/YXV0aG9yaXR5UmV2b2NhdGlvbkxpc3SG
+ Jmh0dHA6Ly9jaC5zaWVtZW5zLmNvbS9wa2k/WlpaWlpaQTEuY3JshkhsZGFwOi8v
+ Y2wuc2llbWVucy5jb20vdWlkPVpaWlpaWkExLG89VHJ1c3RjZW50ZXI/YXV0aG9y
+ aXR5UmV2b2NhdGlvbkxpc3QwJwYDVR0lBCAwHgYIKwYBBQUHAwIGCCsGAQUFBwME
+ BggrBgEFBQcDCTAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFPgVXUcMbHd7csQC
+ F5Foorb3aglEMA0GCSqGSIb3DQEBCwUAA4ICAQBw+sqMp3SS7DVKcILEmXbdRAg3
+ lLO1r457KY+YgCT9uX4VG5EdRKcGfWXK6VHGCi4Dos5eXFV34Mq/p8nu1sqMuoGP
+ YjHn604eWDprhGy6GrTYdxzcE/GGHkpkuE3Ir/45UcmZlOU41SJ9SNjuIVrSHMOf
+ ccSY42BCspR/Q1Z/ykmIqQecdT3/Kkx02GzzSN2+HlW6cEO4GBW5RMqsvd2n0h2d
+ fe2zcqOgkLtx7u2JCR/U77zfyxG3qXtcymoz0wgSHcsKIl+GUjITLkHfS9Op8V7C
+ Gr/dX437sIg5pVHmEAWadjkIzqdHux+EF94Z6kaHywohc1xG0KvPYPX7iSNjkvhz
+ 4NY53DHmxl4YEMLffZnaS/dqyhe1GTpcpyN8WiR4KuPfxrkVDOsuzWFtMSvNdlOV
+ gdI0MXcLMP+EOeANZWX6lGgJ3vWyemo58nzgshKd24MY3w3i6masUkxJH2KvI7UH
+ /1Db3SC8oOUjInvSRej6M3ZhYWgugm6gbpUgFoDw/o9Cg6Qm71hY0JtcaPC13rzm
+ N8a2Br0+Fa5e2VhwLmAxyfe1JKzqPwuHT0S5u05SQghL5VdzqfA8FCL/j4XC9yI6
+ csZTAQi73xFQYVjZt3+aoSz84lOlTmVo/jgvGMY/JzH9I4mETGgAJRNj34Z/0meh
+ M+pKWCojNH/dgyJSwDGCAlIwggJOAgEBMIG/MIG2MQswCQYDVQQGEwJERTEPMA0G
+ A1UECAwGQmF5ZXJuMREwDwYDVQQHDAhNdWVuY2hlbjEQMA4GA1UECgwHU2llbWVu
+ czERMA8GA1UEBRMIWlpaWlpaQTYxHTAbBgNVBAsMFFNpZW1lbnMgVHJ1c3QgQ2Vu
+ dGVyMT8wPQYDVQQDDDZTaWVtZW5zIElzc3VpbmcgQ0EgTWVkaXVtIFN0cmVuZ3Ro
+ IEF1dGhlbnRpY2F0aW9uIDIwMTYCBBXXLOIwCwYJYIZIAWUDBAIBoGkwHAYJKoZI
+ hvcNAQkFMQ8XDTE5MTEyMDE0NTYyMFowLwYJKoZIhvcNAQkEMSIEIJDnZUpcVLzC
+ OdtpkH8gtxwLPIDE0NmAmFC9uM8q2z+OMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0B
+ BwEwCwYJKoZIhvcNAQEBBIIBAH/Pqv2xp3a0jSPkwU1K3eGA/1lfoNJMUny4d/PS
+ LVWlkgrmedXdLmuBzAGEaaZOJS0lEpNd01pR/reHs7xxZ+RZ0olTs2ufM0CijQSx
+ OL9HDl2O3OoD77NWx4tl3Wy1yJCeV3XH/cEI7AkKHCmKY9QMoMYWh16ORBtr+YcS
+ YK+gONOjpjgcgTJgZ3HSFgQ50xiD4WT1kFBHsuYsLqaOSbTfTN6Ayyg4edjrPQqa
+ VcVf1OQcIrfWA3yMQrnEZfOYfN/D4EPjTfxBV+VCi/F2bdZmMbJ7jNk1FbewSwWO
+ SDH1i0K32NyFbnh0BSos7njq7ELqKlYBsoB/sZfaH2vKy5U=
+ -----END SIGNED MESSAGE-----
+ SIGNATURE
+ end
+
+ def signed_tag_base_data
+ <<~SIGNEDDATA
+ object 189a6c924013fc3fe40d6f1ec1dc20214183bc97
+ type commit
+ tag v1.1.1
+ tagger Roger Meier <r.meier@siemens.com> 1574261780 +0100
+
+ x509 signed tag
+ SIGNEDDATA
+ end
+
def certificate_crl
'http://ch.siemens.com/pki?ZZZZZZA2.crl'
end
+ def tag_certificate_crl
+ 'http://ch.siemens.com/pki?ZZZZZZA6.crl'
+ end
+
def certificate_serial
1810356222
end
+ def tag_certificate_serial
+ 3664232660
+ end
+
def certificate_subject_key_identifier
'EC:00:B5:28:02:5C:D3:A5:A1:AB:C2:A1:34:81:84:AA:BF:9B:CF:F8'
end
+ def tag_certificate_subject_key_identifier
+ '21:7E:82:45:29:5D:0E:B1:19:CD:24:45:65:EE:0C:5C:73:03:5E:33'
+ end
+
def issuer_subject_key_identifier
'BD:BD:2A:43:22:3D:48:4A:57:7E:98:31:17:A9:70:9D:EE:9F:A8:99'
end
+ def tag_issuer_subject_key_identifier
+ 'F8:15:5D:47:0C:6C:77:7B:72:C4:02:17:91:68:A2:B6:F7:6A:09:44'
+ end
+
def certificate_email
'r.meier@siemens.com'
end
@@ -197,6 +330,10 @@ module X509Helpers
'CN=Siemens Issuing CA EE Auth 2016,OU=Siemens Trust Center,serialNumber=ZZZZZZA2,O=Siemens,L=Muenchen,ST=Bayern,C=DE'
end
+ def tag_certificate_issuer
+ 'CN=Siemens Issuing CA Medium Strength Authentication 2016,OU=Siemens Trust Center,serialNumber=ZZZZZZA6,O=Siemens,L=Muenchen,ST=Bayern,C=DE'
+ end
+
def certificate_subject
'CN=Meier Roger,O=Siemens,SN=Meier,GN=Roger,serialNumber=Z000NWDH'
end
diff --git a/spec/support/import_export/common_util.rb b/spec/support/import_export/common_util.rb
index 1a5668946c6..0069ae81b76 100644
--- a/spec/support/import_export/common_util.rb
+++ b/spec/support/import_export/common_util.rb
@@ -15,28 +15,9 @@ module ImportExport
export_path = [prefix, 'spec', 'fixtures', 'lib', 'gitlab', 'import_export', name].compact
export_path = File.join(*export_path)
- if File.exist?(File.join(export_path, 'tree.tar.gz'))
- extract_archive(export_path, 'tree.tar.gz')
- end
-
allow_any_instance_of(Gitlab::ImportExport).to receive(:export_path) { export_path }
end
- def extract_archive(path, archive)
- output, exit_status = Gitlab::Popen.popen(["cd #{path}; tar xzf #{archive}"])
-
- raise "Failed to extract archive. Output: #{output}" unless exit_status.zero?
- end
-
- def cleanup_artifacts_from_extract_archive(name, prefix = nil)
- export_path = [prefix, 'spec', 'fixtures', 'lib', 'gitlab', 'import_export', name].compact
- export_path = File.join(*export_path)
-
- if File.exist?(File.join(export_path, 'tree.tar.gz'))
- system("cd #{export_path}; rm -fr tree")
- end
- end
-
def setup_reader(reader)
if reader == :ndjson_reader && Feature.enabled?(:project_import_ndjson)
allow_any_instance_of(Gitlab::ImportExport::JSON::LegacyReader::File).to receive(:exist?).and_return(false)
diff --git a/spec/support/import_export/configuration_helper.rb b/spec/support/import_export/configuration_helper.rb
index 4330c4314a8..6f67b0f3dd7 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 - JSON.parse(excluded_attributes.to_json) if excluded_attributes
- attributes = attributes & JSON.parse(included_attributes.to_json) if included_attributes
+ 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
end
diff --git a/spec/support/kubeclient.rb b/spec/support/kubeclient.rb
new file mode 100644
index 00000000000..56c5800c801
--- /dev/null
+++ b/spec/support/kubeclient.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+RSpec.configure do |config|
+ # Feature specs call webmock_enable_with_http_connect_on_start! by
+ # default. This is needed to prevent Kubeclient from connecting to a
+ # host before the request is stubbed.
+ config.before(:each, :kubeclient) do
+ webmock_enable!
+ end
+end
diff --git a/spec/support/matchers/disallow_request_matchers.rb b/spec/support/matchers/disallow_request_matchers.rb
index a161e3660cd..cb6f4bedbd5 100644
--- a/spec/support/matchers/disallow_request_matchers.rb
+++ b/spec/support/matchers/disallow_request_matchers.rb
@@ -11,7 +11,7 @@ end
RSpec::Matchers.define :disallow_request_in_json do
match do |response|
- json_response = JSON.parse(response.body)
+ json_response = Gitlab::Json.parse(response.body)
response.body.include?('You cannot perform write operations') && json_response.key?('message')
end
end
diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb
index 6439b68764e..3e2193a9069 100644
--- a/spec/support/matchers/graphql_matchers.rb
+++ b/spec/support/matchers/graphql_matchers.rb
@@ -1,8 +1,14 @@
# frozen_string_literal: true
RSpec::Matchers.define :require_graphql_authorizations do |*expected|
- match do |field|
- expect(field.to_graphql.metadata[:authorize]).to eq(*expected)
+ match do |klass|
+ permissions = if klass.respond_to?(:required_permissions)
+ klass.required_permissions
+ else
+ [klass.to_graphql.metadata[:authorize]]
+ end
+
+ expect(permissions).to eq(expected)
end
end
diff --git a/spec/support/rails/test_case_patch.rb b/spec/support/rails/test_case_patch.rb
deleted file mode 100644
index 161e1ef2a4c..00000000000
--- a/spec/support/rails/test_case_patch.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-#
-# This file pulls in the changes in https://github.com/rails/rails/pull/38063
-# to fix controller specs updated with the latest Rack versions.
-#
-# This file should be removed after that change ships. It is not
-# present in Rails 6.0.2.2.
-module ActionController
- class TestRequest < ActionDispatch::TestRequest #:nodoc:
- def self.new_session
- TestSessionPatched.new
- end
- end
-
- # Methods #destroy and #load! are overridden to avoid calling methods on the
- # @store object, which does not exist for the TestSession class.
- class TestSessionPatched < Rack::Session::Abstract::PersistedSecure::SecureSessionHash #:nodoc:
- DEFAULT_OPTIONS = Rack::Session::Abstract::Persisted::DEFAULT_OPTIONS
-
- def initialize(session = {})
- super(nil, nil)
- @id = Rack::Session::SessionId.new(SecureRandom.hex(16))
- @data = stringify_keys(session)
- @loaded = true
- end
-
- def exists?
- true
- end
-
- def keys
- @data.keys
- end
-
- def values
- @data.values
- end
-
- def destroy
- clear
- end
-
- def fetch(key, *args, &block)
- @data.fetch(key.to_s, *args, &block)
- end
-
- private
-
- def load!
- @id
- end
- end
-end
diff --git a/spec/support/redis/redis_shared_examples.rb b/spec/support/redis/redis_shared_examples.rb
index 1e2d11a66cb..f5f6a69738b 100644
--- a/spec/support/redis/redis_shared_examples.rb
+++ b/spec/support/redis/redis_shared_examples.rb
@@ -150,7 +150,7 @@ RSpec.shared_examples "redis_shared_examples" do
it 'returns an array of hashes with host and port keys' do
is_expected.to include(host: 'localhost', port: sentinel_port)
- is_expected.to include(host: 'slave2', port: sentinel_port)
+ is_expected.to include(host: 'replica2', port: sentinel_port)
end
end
diff --git a/spec/support/renameable_upload.rb b/spec/support/renameable_upload.rb
new file mode 100644
index 00000000000..f7f00181605
--- /dev/null
+++ b/spec/support/renameable_upload.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class RenameableUpload < SimpleDelegator
+ attr_accessor :original_filename
+
+ # Get a fixture file with a new unique name, and the same extension
+ def self.unique_file(name)
+ upload = new(fixture_file_upload("spec/fixtures/#{name}"))
+ ext = File.extname(name)
+ new_name = File.basename(FactoryBot.generate(:filename), '.*')
+ upload.original_filename = new_name + ext
+
+ upload
+ end
+end
diff --git a/spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb b/spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb
new file mode 100644
index 00000000000..04f49e94647
--- /dev/null
+++ b/spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+shared_examples 'allowed user IDs are cached' do
+ it 'caches the allowed user IDs in cache', :use_clean_rails_memory_store_caching do
+ expect do
+ expect(described_class.l1_cache_backend).to receive(:fetch).and_call_original
+ expect(described_class.l2_cache_backend).not_to receive(:fetch)
+ expect(subject).to be_truthy
+ end.not_to exceed_query_limit(0)
+ end
+
+ it 'caches the allowed user IDs in L1 cache for 1 minute', :use_clean_rails_memory_store_caching do
+ Timecop.travel 2.minutes do
+ expect do
+ expect(described_class.l1_cache_backend).to receive(:fetch).and_call_original
+ expect(described_class.l2_cache_backend).to receive(:fetch).and_call_original
+ expect(subject).to be_truthy
+ end.not_to exceed_query_limit(0)
+ end
+ end
+
+ it 'caches the allowed user IDs in L2 cache for 5 minutes', :use_clean_rails_memory_store_caching do
+ Timecop.travel 6.minutes do
+ expect do
+ expect(described_class.l1_cache_backend).to receive(:fetch).and_call_original
+ expect(described_class.l2_cache_backend).to receive(:fetch).and_call_original
+ expect(subject).to be_truthy
+ end.not_to exceed_query_limit(2)
+ end
+ end
+end
diff --git a/spec/support/shared_contexts/design_management_shared_contexts.rb b/spec/support/shared_contexts/design_management_shared_contexts.rb
new file mode 100644
index 00000000000..2866effb3a8
--- /dev/null
+++ b/spec/support/shared_contexts/design_management_shared_contexts.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+shared_context 'four designs in three versions' do
+ include DesignManagementTestHelpers
+
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:project) { issue.project }
+ let_it_be(:authorized_user) { create(:user) }
+
+ let_it_be(:design_a) { create(:design, issue: issue) }
+ let_it_be(:design_b) { create(:design, issue: issue) }
+ let_it_be(:design_c) { create(:design, issue: issue) }
+ let_it_be(:design_d) { create(:design, issue: issue) }
+
+ let_it_be(:first_version) do
+ create(:design_version, issue: issue,
+ created_designs: [design_a],
+ modified_designs: [],
+ deleted_designs: [])
+ end
+ let_it_be(:second_version) do
+ create(:design_version, issue: issue,
+ created_designs: [design_b, design_c, design_d],
+ modified_designs: [design_a],
+ deleted_designs: [])
+ end
+ let_it_be(:third_version) do
+ create(:design_version, issue: issue,
+ created_designs: [],
+ modified_designs: [design_a],
+ deleted_designs: [design_d])
+ end
+
+ before do
+ enable_design_management
+ project.add_developer(authorized_user)
+ end
+end
diff --git a/spec/support/shared_contexts/features/error_tracking_shared_context.rb b/spec/support/shared_contexts/features/error_tracking_shared_context.rb
index cbd33dd109b..102cf7c9b11 100644
--- a/spec/support/shared_contexts/features/error_tracking_shared_context.rb
+++ b/spec/support/shared_contexts/features/error_tracking_shared_context.rb
@@ -6,9 +6,9 @@ shared_context 'sentry error tracking context feature' do
let_it_be(:project) { create(:project) }
let_it_be(:project_error_tracking_settings) { create(:project_error_tracking_setting, project: project) }
let_it_be(:issue_response_body) { fixture_file('sentry/issue_sample_response.json') }
- let_it_be(:issue_response) { JSON.parse(issue_response_body) }
+ let_it_be(:issue_response) { Gitlab::Json.parse(issue_response_body) }
let_it_be(:event_response_body) { fixture_file('sentry/issue_latest_event_sample_response.json') }
- let_it_be(:event_response) { JSON.parse(event_response_body) }
+ let_it_be(:event_response) { Gitlab::Json.parse(event_response_body) }
let(:sentry_api_urls) { Sentry::ApiUrls.new(project_error_tracking_settings.api_url) }
let(:issue_id) { issue_response['id'] }
let(:issue_seen) { 1.year.ago.utc }
diff --git a/spec/support/shared_contexts/issuable/merge_request_shared_context.rb b/spec/support/shared_contexts/issuable/merge_request_shared_context.rb
new file mode 100644
index 00000000000..05ffb934c34
--- /dev/null
+++ b/spec/support/shared_contexts/issuable/merge_request_shared_context.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+shared_context 'merge request show action' do
+ include Devise::Test::ControllerHelpers
+ include ProjectForksHelper
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public, :repository) }
+ let(:forked_project) { fork_project(project, user, repository: true) }
+ let(:unlink_project) { Projects::UnlinkForkService.new(forked_project, user) }
+ let(:note) { create(:note_on_merge_request, project: project, noteable: closed_merge_request) }
+
+ let(:closed_merge_request) do
+ create(:closed_merge_request,
+ source_project: forked_project,
+ target_project: project,
+ author: user)
+ end
+
+ def preload_view_requirements
+ # This will load the status fields of the author of the note and merge request
+ # to avoid queries in when rendering the view being tested.
+ closed_merge_request.author.status
+ note.author.status
+ end
+
+ before do
+ assign(:project, project)
+ assign(:merge_request, closed_merge_request)
+ assign(:commits_count, 0)
+ assign(:note, note)
+ assign(:noteable, closed_merge_request)
+ assign(:notes, [])
+ assign(:pipelines, Ci::Pipeline.none)
+ assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, closed_merge_request))
+
+ preload_view_requirements
+
+ allow(view).to receive_messages(current_user: user,
+ can?: true,
+ current_application_settings: Gitlab::CurrentSettings.current_application_settings)
+ end
+
+ def serialize_issuable_sidebar(user, project, merge_request)
+ MergeRequestSerializer
+ .new(current_user: user, project: project)
+ .represent(closed_merge_request, serializer: 'sidebar')
+ end
+end
diff --git a/spec/support/shared_contexts/issuable/project_shared_context.rb b/spec/support/shared_contexts/issuable/project_shared_context.rb
new file mode 100644
index 00000000000..6dbf3154977
--- /dev/null
+++ b/spec/support/shared_contexts/issuable/project_shared_context.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+shared_context 'project show action' do
+ let(:project) { create(:project, :repository) }
+ let(:issue) { create(:issue, project: project, author: user) }
+ let(:user) { create(:user) }
+
+ before do
+ assign(:project, project)
+ assign(:issue, issue)
+ assign(:noteable, issue)
+ stub_template 'shared/issuable/_sidebar' => ''
+ stub_template 'projects/issues/_discussion' => ''
+ allow(view).to receive(:user_status).and_return('')
+ end
+end
diff --git a/spec/support/shared_contexts/json_response_shared_context.rb b/spec/support/shared_contexts/json_response_shared_context.rb
index 6a0734decd5..2f0a564d2bc 100644
--- a/spec/support/shared_contexts/json_response_shared_context.rb
+++ b/spec/support/shared_contexts/json_response_shared_context.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
RSpec.shared_context 'JSON response' do
- let(:json_response) { JSON.parse(response.body) }
+ let(:json_response) { Gitlab::Json.parse(response.body) }
end
diff --git a/spec/support/shared_contexts/lib/gitlab/import_export/project/rake_task_object_storage_shared_context.rb b/spec/support/shared_contexts/lib/gitlab/import_export/project/rake_task_object_storage_shared_context.rb
new file mode 100644
index 00000000000..dc1a52e3629
--- /dev/null
+++ b/spec/support/shared_contexts/lib/gitlab/import_export/project/rake_task_object_storage_shared_context.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'rake task object storage shared context' do
+ before do
+ allow(Settings.uploads.object_store).to receive(:[]=).and_call_original
+ end
+
+ around do |example|
+ old_object_store_setting = Settings.uploads.object_store['enabled']
+
+ Settings.uploads.object_store['enabled'] = true
+
+ example.run
+
+ Settings.uploads.object_store['enabled'] = old_object_store_setting
+ end
+end
diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb
index 4606608866a..fe3c32ec0c5 100644
--- a/spec/support/shared_contexts/navbar_structure_context.rb
+++ b/spec/support/shared_contexts/navbar_structure_context.rb
@@ -62,6 +62,7 @@ RSpec.shared_context 'project navbar structure' do
nav_item: _('Operations'),
nav_sub_items: [
_('Metrics'),
+ _('Alerts'),
_('Environments'),
_('Error Tracking'),
_('Serverless'),
@@ -85,6 +86,7 @@ RSpec.shared_context 'project navbar structure' do
_('Members'),
_('Integrations'),
_('Webhooks'),
+ _('Access Tokens'),
_('Repository'),
_('CI / CD'),
_('Operations'),
diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb
index c2797c49c02..a0d54666dff 100644
--- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb
+++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb
@@ -14,17 +14,16 @@ RSpec.shared_context 'GroupPolicy context' do
%i[
read_label read_group upload_file read_namespace read_group_activity
read_group_issues read_group_boards read_group_labels read_group_milestones
- read_group_merge_requests read_wiki
+ read_group_merge_requests
]
end
let(:read_group_permissions) { %i[read_label read_list read_milestone read_board] }
- let(:reporter_permissions) { %i[admin_label read_container_image read_metrics_dashboard_annotation download_wiki_code] }
- let(:developer_permissions) { %i[admin_milestone create_metrics_dashboard_annotation delete_metrics_dashboard_annotation update_metrics_dashboard_annotation create_wiki] }
+ let(:reporter_permissions) { %i[admin_label read_container_image read_metrics_dashboard_annotation] }
+ let(:developer_permissions) { %i[admin_milestone create_metrics_dashboard_annotation delete_metrics_dashboard_annotation update_metrics_dashboard_annotation] }
let(:maintainer_permissions) do
%i[
create_projects
read_cluster create_cluster update_cluster admin_cluster add_cluster
- admin_wiki
]
end
let(:owner_permissions) do
@@ -35,7 +34,8 @@ RSpec.shared_context 'GroupPolicy context' do
:change_visibility_level,
:set_note_created_at,
:create_subgroup,
- :read_statistics
+ :read_statistics,
+ :update_default_branch_protection
].compact
end
diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb
index 055164ec38e..5339fa003b9 100644
--- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb
+++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb
@@ -39,7 +39,7 @@ RSpec.shared_context 'ProjectPolicy context' do
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
+ update_environment daily_statistics
]
end
@@ -49,7 +49,6 @@ RSpec.shared_context 'ProjectPolicy context' do
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
- daily_statistics
]
end
diff --git a/spec/support/shared_contexts/project_service_shared_context.rb b/spec/support/shared_contexts/project_service_shared_context.rb
index 89b196e7039..21d67ea71a8 100644
--- a/spec/support/shared_contexts/project_service_shared_context.rb
+++ b/spec/support/shared_contexts/project_service_shared_context.rb
@@ -5,6 +5,7 @@ shared_context 'project service activation' do
let(:user) { create(:user) }
before do
+ stub_feature_flags(integration_form_refactor: false)
project.add_maintainer(user)
sign_in(user)
end
@@ -18,6 +19,10 @@ shared_context 'project service activation' do
click_link(name)
end
+ def click_active_toggle
+ find('input[name="service[active]"] + button').click
+ end
+
def click_test_integration
click_button('Test settings and save changes')
end
diff --git a/spec/support/shared_contexts/services_shared_context.rb b/spec/support/shared_contexts/services_shared_context.rb
index 21bc0651c44..bf4eac8f324 100644
--- a/spec/support/shared_contexts/services_shared_context.rb
+++ b/spec/support/shared_contexts/services_shared_context.rb
@@ -31,8 +31,7 @@ Service.available_services_names.each do |service|
let(:licensed_features) do
{
'github' => :github_project_service_integration,
- 'jenkins' => :jenkins_integration,
- 'jenkins_deprecated' => :jenkins_integration
+ 'jenkins' => :jenkins_integration
}
end
diff --git a/spec/support/shared_contexts/spam_constants.rb b/spec/support/shared_contexts/spam_constants.rb
new file mode 100644
index 00000000000..b6e92ea3050
--- /dev/null
+++ b/spec/support/shared_contexts/spam_constants.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+shared_context 'includes Spam constants' do
+ REQUIRE_RECAPTCHA = Spam::SpamConstants::REQUIRE_RECAPTCHA
+ DISALLOW = Spam::SpamConstants::DISALLOW
+ ALLOW = Spam::SpamConstants::ALLOW
+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
index e4d59463d93..17087456720 100644
--- a/spec/support/shared_examples/controllers/instance_statistics_controllers_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/instance_statistics_controllers_shared_examples.rb
@@ -27,12 +27,24 @@ RSpec.shared_examples 'instance statistics availability' do
context 'for admins' do
let(:user) { create(:admin) }
- it 'allows access when the feature is not available publicly' do
- stub_application_setting(instance_statistics_visibility_private: true)
+ 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
+ get :index
- expect(response).to have_gitlab_http_status(:success)
+ 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
diff --git a/spec/support/shared_examples/controllers/known_sign_in_shared_examples.rb b/spec/support/shared_examples/controllers/known_sign_in_shared_examples.rb
new file mode 100644
index 00000000000..60abb76acec
--- /dev/null
+++ b/spec/support/shared_examples/controllers/known_sign_in_shared_examples.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'known sign in' do
+ def stub_remote_ip(ip)
+ request.remote_ip = ip
+ end
+
+ def stub_user_ip(ip)
+ user.update!(current_sign_in_ip: ip)
+ end
+
+ context 'with a valid post' do
+ context 'when remote IP does not match user last sign in IP' do
+ before do
+ stub_user_ip('127.0.0.1')
+ stub_remote_ip('169.0.0.1')
+ end
+
+ it 'notifies the user' do
+ expect_next_instance_of(NotificationService) do |instance|
+ expect(instance).to receive(:unknown_sign_in)
+ end
+
+ post_action
+ end
+ end
+
+ context 'when remote IP matches an active session' do
+ before do
+ existing_sessions = ActiveSession.session_ids_for_user(user.id)
+ existing_sessions.each { |sessions| ActiveSession.destroy(user, sessions) }
+
+ stub_user_ip('169.0.0.1')
+ stub_remote_ip('127.0.0.1')
+
+ ActiveSession.set(user, request)
+ end
+
+ it 'does not notify the user' do
+ expect_any_instance_of(NotificationService).not_to receive(:unknown_sign_in)
+
+ post_action
+ end
+ end
+
+ context 'when remote IP address matches last sign in IP' do
+ before do
+ stub_user_ip('127.0.0.1')
+ stub_remote_ip('127.0.0.1')
+ end
+
+ it 'does not notify the user' do
+ expect_any_instance_of(NotificationService).not_to receive(:unknown_sign_in)
+
+ post_action
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/controllers/variables_shared_examples.rb b/spec/support/shared_examples/controllers/variables_shared_examples.rb
index 752bdc47851..9ff0bc3d217 100644
--- a/spec/support/shared_examples/controllers/variables_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/variables_shared_examples.rb
@@ -27,6 +27,9 @@ RSpec.shared_examples 'PATCH #update updates variables' do
protected: 'false' }
end
+ let(:variables_scope) { owner.variables }
+ let(:file_variables_scope) { owner.variables.file }
+
context 'with invalid new variable parameters' do
let(:variables_attributes) do
[
@@ -40,7 +43,7 @@ RSpec.shared_examples 'PATCH #update updates variables' do
end
it 'does not create the new variable' do
- expect { subject }.not_to change { owner.variables.count }
+ expect { subject }.not_to change { variables_scope.count }
end
it 'returns a bad request response' do
@@ -63,7 +66,7 @@ RSpec.shared_examples 'PATCH #update updates variables' do
end
it 'does not create the new variable' do
- expect { subject }.not_to change { owner.variables.count }
+ expect { subject }.not_to change { variables_scope.count }
end
it 'returns a bad request response' do
@@ -86,7 +89,7 @@ RSpec.shared_examples 'PATCH #update updates variables' do
end
it 'creates the new variable' do
- expect { subject }.to change { owner.variables.count }.by(1)
+ expect { subject }.to change { variables_scope.count }.by(1)
end
it 'returns a successful response' do
@@ -106,7 +109,7 @@ RSpec.shared_examples 'PATCH #update updates variables' do
let(:variables_attributes) { [variable_attributes.merge(_destroy: 'true')] }
it 'destroys the variable' do
- expect { subject }.to change { owner.variables.count }.by(-1)
+ expect { subject }.to change { variables_scope.count }.by(-1)
expect { variable.reload }.to raise_error ActiveRecord::RecordNotFound
end
@@ -123,6 +126,18 @@ RSpec.shared_examples 'PATCH #update updates variables' do
end
end
+ context 'with missing variable' do
+ let(:variables_attributes) do
+ [variable_attributes.merge(_destroy: 'true', id: 'some-id')]
+ end
+
+ it 'returns not found response' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
context 'for variables of type file' do
let(:variables_attributes) do
[
@@ -131,7 +146,7 @@ RSpec.shared_examples 'PATCH #update updates variables' do
end
it 'creates new variable of type file' do
- expect { subject }.to change { owner.variables.file.count }.by(1)
+ expect { subject }.to change { file_variables_scope.count }.by(1)
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 922d2627bce..1cd05b22ae9 100644
--- a/spec/support/shared_examples/features/error_tracking_shared_example.rb
+++ b/spec/support/shared_examples/features/error_tracking_shared_example.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
shared_examples 'error tracking index page' do
- it 'renders the error index page' do
+ it 'renders the error index page', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/217810' } do
within('div.js-title-container') do
expect(page).to have_content(project.namespace.name)
expect(page).to have_content(project.name)
@@ -15,7 +15,7 @@ shared_examples 'error tracking index page' do
end
end
- it 'loads the error show page on click' do
+ it 'loads the error show page on click', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/217810' } do
click_on issues_response[0]['title']
wait_for_requests
@@ -23,7 +23,7 @@ shared_examples 'error tracking index page' do
expect(page).to have_content('Error Details')
end
- it 'renders the error index data' do
+ it 'renders the error index data', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/217810' } do
within('div.error-list') do
expect(page).to have_content(issues_response[0]['title'])
expect(page).to have_content(issues_response[0]['count'].to_s)
@@ -34,7 +34,7 @@ shared_examples 'error tracking index page' do
end
shared_examples 'expanded stack trace context' do |selected_line: nil, expected_line: 1|
- it 'expands the stack trace context' do
+ 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
@@ -49,7 +49,7 @@ shared_examples 'expanded stack trace context' do |selected_line: nil, expected_
end
shared_examples 'error tracking show page' do
- it 'renders the error details' do
+ it 'renders the error details', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/217810' } do
content = page.find(".content")
nav = page.find("nav.breadcrumbs")
header = page.find(".error-details-header")
@@ -67,11 +67,11 @@ shared_examples 'error tracking show page' do
expect(content).to have_content('Users: 0')
end
- it 'renders the stack trace heading' do
+ it 'renders the stack trace heading', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/217810' } do
expect(page).to have_content('Stack trace')
end
- it 'renders the stack trace' do
+ it 'renders the stack trace', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/217810' } do
event_response['entries'][0]['data']['values'][0]['stacktrace']['frames'].each do |frame|
expect(frame['filename']).not_to be_nil
expect(page).to have_content(frame['filename'])
diff --git a/spec/support/shared_examples/features/variable_list_shared_examples.rb b/spec/support/shared_examples/features/variable_list_shared_examples.rb
index 4fd4d42003f..218ef070221 100644
--- a/spec/support/shared_examples/features/variable_list_shared_examples.rb
+++ b/spec/support/shared_examples/features/variable_list_shared_examples.rb
@@ -29,7 +29,6 @@ RSpec.shared_examples 'variable list' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('key')
find('.js-ci-variable-input-value').set('key_value')
- find('.ci-variable-protected-item .js-project-feature-toggle').click
expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
end
@@ -173,6 +172,7 @@ RSpec.shared_examples 'variable list' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('unprotected_key')
find('.js-ci-variable-input-value').set('unprotected_value')
+ find('.ci-variable-protected-item .js-project-feature-toggle').click
expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false')
end
@@ -207,7 +207,6 @@ RSpec.shared_examples 'variable list' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('protected_key')
find('.js-ci-variable-input-value').set('protected_value')
- find('.ci-variable-protected-item .js-project-feature-toggle').click
expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
end
diff --git a/spec/support/shared_examples/graphql/design_fields_shared_examples.rb b/spec/support/shared_examples/graphql/design_fields_shared_examples.rb
new file mode 100644
index 00000000000..029d7e677da
--- /dev/null
+++ b/spec/support/shared_examples/graphql/design_fields_shared_examples.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+# To use these shared examples, you may define a value in scope named
+# `extra_design_fields`, to pass any extra fields in addition to the
+# standard design fields.
+RSpec.shared_examples 'a GraphQL type with design fields' do
+ let(:extra_design_fields) { [] }
+
+ it { expect(described_class).to require_graphql_authorizations(:read_design) }
+
+ it 'exposes the expected design fields' do
+ expected_fields = %i[
+ id
+ project
+ issue
+ filename
+ full_path
+ image
+ image_v432x230
+ diff_refs
+ event
+ notes_count
+ ] + extra_design_fields
+
+ expect(described_class).to have_graphql_fields(*expected_fields).only
+ end
+
+ describe '#image' do
+ let(:schema) { GitlabSchema }
+ let(:query) { GraphQL::Query.new(schema) }
+ let(:context) { double('Context', schema: schema, query: query, parent: nil) }
+ let(:field) { described_class.fields['image'] }
+ let(:args) { GraphQL::Query::Arguments::NO_ARGS }
+ let(:instance) do
+ object = GitlabSchema.sync_lazy(GitlabSchema.object_from_id(object_id))
+ object_type.authorized_new(object, query.context)
+ end
+ let(:instance_b) do
+ object_b = GitlabSchema.sync_lazy(GitlabSchema.object_from_id(object_id_b))
+ object_type.authorized_new(object_b, query.context)
+ end
+
+ it 'resolves to the design image URL' do
+ image = field.resolve_field(instance, args, context)
+ sha = design.versions.first.sha
+ url = ::Gitlab::Routing.url_helpers.project_design_management_designs_raw_image_url(design.project, design, sha)
+
+ expect(image).to eq(url)
+ end
+
+ it 'has better than O(N) peformance', :request_store do
+ # Assuming designs have been loaded (as they must be), the following
+ # queries are required:
+ # For each distinct version:
+ # - design_management_versions
+ # (Request store is needed so that each version is fetched only once.)
+ # For each distinct issue
+ # - issues
+ # For each distinct project
+ # - projects
+ # - routes
+ # - namespaces
+ # Here that total is:
+ # - 2 x issues
+ # - 2 x versions
+ # - 2 x (projects + routes + namespaces)
+ # = 10
+ expect(instance).not_to eq(instance_b) # preload designs themselves.
+ expect do
+ image_a = field.resolve_field(instance, args, context)
+ image_b = field.resolve_field(instance, args, context)
+ image_c = field.resolve_field(instance_b, args, context)
+ image_d = field.resolve_field(instance_b, args, context)
+ expect(image_a).to eq(image_b)
+ expect(image_c).not_to eq(image_b)
+ expect(image_c).to eq(image_d)
+ end.not_to exceed_query_limit(10)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/graphql/jira_import/jira_import_resolver_shared_examples.rb b/spec/support/shared_examples/graphql/jira_import/jira_import_resolver_shared_examples.rb
index 3d97fe10a47..2b96010477c 100644
--- a/spec/support/shared_examples/graphql/jira_import/jira_import_resolver_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/jira_import/jira_import_resolver_shared_examples.rb
@@ -1,12 +1,12 @@
# frozen_string_literal: true
-shared_examples 'no jira import data present' do
+shared_examples 'no Jira import data present' do
it 'returns none' do
expect(resolve_imports).to eq JiraImportState.none
end
end
-shared_examples 'no jira import access' do
+shared_examples 'no Jira import access' do
it 'raises error' do
expect do
resolve_imports
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
new file mode 100644
index 00000000000..fb7e24eecf2
--- /dev/null
+++ b/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+# Use this for testing how a GraphQL query handles sorting and pagination.
+# This is particularly important when using keyset pagination connection,
+# which is the default for ActiveRecord relations, as certain sort keys
+# might not be supportable.
+#
+# sort_param: the value to specify the sort
+# data_path: the keys necessary to dig into the return GraphQL data to get the
+# returned results
+# first_param: number of items expected (like a page size)
+# expected_results: array of comparison data of all items sorted correctly
+# pagination_query: method that specifies the GraphQL query
+# pagination_results_data: method that extracts the sorted data used to compare against
+# the expected results
+#
+# Example:
+# describe 'sorting and pagination' do
+# let(:sort_project) { create(:project, :public) }
+# let(:data_path) { [:project, :issues] }
+#
+# def pagination_query(params, page_info)
+# graphql_query_for(
+# 'project',
+# { 'fullPath' => sort_project.full_path },
+# "issues(#{params}) { #{page_info} edges { node { iid weight } } }"
+# )
+# end
+#
+# def pagination_results_data(data)
+# data.map { |issue| issue.dig('node', 'iid').to_i }
+# end
+#
+# context 'when sorting by weight' do
+# ...
+# context 'when ascending' do
+# it_behaves_like 'sorted paginated query' do
+# let(:sort_param) { 'WEIGHT_ASC' }
+# let(:first_param) { 2 }
+# let(:expected_results) { [weight_issue3.iid, weight_issue5.iid, weight_issue1.iid, weight_issue4.iid, weight_issue2.iid] }
+# end
+# end
+#
+RSpec.shared_examples 'sorted paginated query' do
+ it_behaves_like 'requires variables' do
+ let(:required_variables) { [:sort_param, :first_param, :expected_results, :data_path, :current_user] }
+ end
+
+ describe do
+ let(:params) { "sort: #{sort_param}" }
+ let(:start_cursor) { graphql_data_at(*data_path, :pageInfo, :startCursor) }
+ let(:end_cursor) { graphql_data_at(*data_path, :pageInfo, :endCursor) }
+ let(:sorted_edges) { graphql_data_at(*data_path, :edges) }
+ let(:page_info) { "pageInfo { startCursor endCursor }" }
+
+ def pagination_query(params, page_info)
+ raise('pagination_query(params, page_info) must be defined in the test, see example in comment') unless defined?(super)
+
+ super
+ end
+
+ def pagination_results_data(data)
+ raise('pagination_results_data(data) must be defined in the test, see example in comment') unless defined?(super)
+
+ super(data)
+ end
+
+ before do
+ post_graphql(pagination_query(params, page_info), current_user: current_user)
+ end
+
+ context 'when sorting' do
+ it 'sorts correctly' do
+ expect(pagination_results_data(sorted_edges)).to eq expected_results
+ end
+
+ context 'when paginating' do
+ let(:params) { "sort: #{sort_param}, first: #{first_param}" }
+
+ it 'paginates correctly' do
+ expect(pagination_results_data(sorted_edges)).to eq expected_results.first(first_param)
+
+ cursored_query = pagination_query("sort: #{sort_param}, after: \"#{end_cursor}\"", page_info)
+ post_graphql(cursored_query, current_user: current_user)
+ 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)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/helm_commands_shared_examples.rb b/spec/support/shared_examples/helm_commands_shared_examples.rb
new file mode 100644
index 00000000000..f0624fbf29f
--- /dev/null
+++ b/spec/support/shared_examples/helm_commands_shared_examples.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+shared_examples 'helm command generator' do
+ describe '#generate_script' do
+ let(:helm_setup) do
+ <<~EOS
+ set -xeo pipefail
+ EOS
+ end
+
+ it 'returns appropriate command' do
+ expect(subject.generate_script.strip).to eq((helm_setup + commands).strip)
+ end
+ end
+end
+
+shared_examples 'helm command' do
+ describe '#rbac?' do
+ subject { command.rbac? }
+
+ context 'rbac is enabled' do
+ let(:rbac) { true }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'rbac is not enabled' do
+ let(:rbac) { false }
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ describe '#pod_resource' do
+ subject { command.pod_resource }
+
+ context 'rbac is enabled' do
+ let(:rbac) { true }
+
+ it { is_expected.to be_an_instance_of ::Kubeclient::Resource }
+
+ it 'generates a pod that uses the tiller serviceAccountName' do
+ expect(subject.spec.serviceAccountName).to eq('tiller')
+ end
+ end
+
+ context 'rbac is not enabled' do
+ let(:rbac) { false }
+
+ it { is_expected.to be_an_instance_of ::Kubeclient::Resource }
+
+ it 'generates a pod that uses the default serviceAccountName' do
+ expect(subject.spec.serviceAcccountName).to be_nil
+ end
+ end
+ end
+
+ describe '#config_map_resource' do
+ subject { command.config_map_resource }
+
+ let(:metadata) do
+ {
+ name: "values-content-configuration-#{command.name}",
+ namespace: 'gitlab-managed-apps',
+ labels: { name: "values-content-configuration-#{command.name}" }
+ }
+ end
+
+ let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: command.files) }
+
+ it 'returns a KubeClient resource with config map content for the application' do
+ is_expected.to eq(resource)
+ end
+ end
+
+ describe '#service_account_resource' do
+ let(:resource) do
+ Kubeclient::Resource.new(metadata: { name: 'tiller', namespace: 'gitlab-managed-apps' })
+ end
+
+ subject { command.service_account_resource }
+
+ context 'rbac is enabled' do
+ let(:rbac) { true }
+
+ it 'generates a Kubeclient resource for the tiller ServiceAccount' do
+ is_expected.to eq(resource)
+ end
+ end
+
+ context 'rbac is not enabled' do
+ let(:rbac) { false }
+
+ it 'generates nothing' do
+ is_expected.to be_nil
+ end
+ end
+ end
+
+ describe '#cluster_role_binding_resource' do
+ let(:resource) do
+ Kubeclient::Resource.new(
+ metadata: { name: 'tiller-admin' },
+ roleRef: { apiGroup: 'rbac.authorization.k8s.io', kind: 'ClusterRole', name: 'cluster-admin' },
+ subjects: [{ kind: 'ServiceAccount', name: 'tiller', namespace: 'gitlab-managed-apps' }]
+ )
+ end
+
+ subject(:cluster_role_binding_resource) { command.cluster_role_binding_resource }
+
+ context 'rbac is enabled' do
+ let(:rbac) { true }
+
+ it 'generates a Kubeclient resource for the ClusterRoleBinding for tiller' do
+ is_expected.to eq(resource)
+ end
+
+ it 'binds the account in #service_account_resource' do
+ expect(cluster_role_binding_resource.subjects.first.name).to eq(command.service_account_resource.metadata.name)
+ end
+ end
+
+ context 'rbac is not enabled' do
+ let(:rbac) { false }
+
+ it 'generates nothing' do
+ is_expected.to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/cycle_analytics/base_stage_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/cycle_analytics/base_stage_shared_examples.rb
index 851ed9c65a3..14292f70228 100644
--- a/spec/support/shared_examples/lib/gitlab/cycle_analytics/base_stage_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/cycle_analytics/base_stage_shared_examples.rb
@@ -63,7 +63,7 @@ shared_examples 'Gitlab::Analytics::CycleAnalytics::DataCollector backend exampl
context 'provides the same results as the old implementation' do
it 'for the median' do
- expect(data_collector.median.seconds).to eq(ISSUES_MEDIAN)
+ expect(data_collector.median.seconds).to be_within(0.5).of(ISSUES_MEDIAN)
end
it 'for the list of event records' do
diff --git a/spec/support/shared_examples/lib/gitlab/helm_generated_script_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/helm_generated_script_shared_examples.rb
deleted file mode 100644
index bbf8a946f8b..00000000000
--- a/spec/support/shared_examples/lib/gitlab/helm_generated_script_shared_examples.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'helm commands' do
- describe '#generate_script' do
- let(:helm_setup) do
- <<~EOS
- set -xeo pipefail
- EOS
- end
-
- it 'returns appropriate command' do
- expect(subject.generate_script.strip).to eq((helm_setup + commands).strip)
- end
- end
-end
diff --git a/spec/support/shared_examples/lib/gitlab/import_export/project/rake_task_object_storage_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/import_export/project/rake_task_object_storage_shared_examples.rb
new file mode 100644
index 00000000000..d6dc89a2c15
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/import_export/project/rake_task_object_storage_shared_examples.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'rake task with disabled object_storage' do |service_class, method|
+ it 'disables direct & background upload only for service call' do
+ expect_next_instance_of(service_class) do |service|
+ expect(service).to receive(:execute).and_wrap_original do |m|
+ expect(Settings.uploads.object_store['enabled']).to eq(false)
+
+ m.call
+ end
+ end
+
+ expect(rake_task).to receive(method).and_wrap_original do |m, *args|
+ expect(Settings.uploads.object_store['enabled']).to eq(true)
+ expect(Settings.uploads.object_store).not_to receive(:[]=).with('enabled', false)
+
+ m.call(*args)
+ end
+
+ subject
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/migration_helpers_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/migration_helpers_shared_examples.rb
index 8893ed5504b..72d672fd36c 100644
--- a/spec/support/shared_examples/lib/gitlab/migration_helpers_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/migration_helpers_shared_examples.rb
@@ -13,10 +13,11 @@ end
RSpec.shared_examples 'performs validation' do |validation_option|
it 'performs validation' do
expect(model).to receive(:disable_statement_timeout).and_call_original
+ expect(model).to receive(:statement_timeout_disabled?).and_return(false)
expect(model).to receive(:execute).with(/statement_timeout/)
expect(model).to receive(:execute).ordered.with(/NOT VALID/)
expect(model).to receive(:execute).ordered.with(/VALIDATE CONSTRAINT/)
- expect(model).to receive(:execute).with(/RESET ALL/)
+ expect(model).to receive(:execute).ordered.with(/RESET ALL/)
model.add_concurrent_foreign_key(*args, **options.merge(validation_option))
end
diff --git a/spec/support/shared_examples/models/chat_service_shared_examples.rb b/spec/support/shared_examples/models/chat_service_shared_examples.rb
index 1cc1a1c8176..0a1c27b32db 100644
--- a/spec/support/shared_examples/models/chat_service_shared_examples.rb
+++ b/spec/support/shared_examples/models/chat_service_shared_examples.rb
@@ -198,7 +198,7 @@ RSpec.shared_examples "chat service" do |service_name|
message: "user created page: Awesome wiki_page"
}
end
- let(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: opts) }
+ let(:wiki_page) { create(:wiki_page, wiki: project.wiki, **opts) }
let(:sample_data) { Gitlab::DataBuilder::WikiPage.build(wiki_page, user, "create") }
it_behaves_like "triggered #{service_name} service"
diff --git a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb
index 37f1b33d455..c2fd04d648b 100644
--- a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb
+++ b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb
@@ -194,6 +194,66 @@ RSpec.shared_examples 'cluster application status specs' do |application_name|
end
end
+ describe '#make_externally_installed' do
+ subject { create(application_name, :installing) }
+
+ it 'is installed' do
+ subject.make_externally_installed
+
+ expect(subject).to be_installed
+ end
+
+ context 'application is updated' do
+ subject { create(application_name, :updated) }
+
+ it 'is installed' do
+ subject.make_externally_installed
+
+ expect(subject).to be_installed
+ end
+ end
+
+ context 'application is errored' do
+ subject { create(application_name, :errored) }
+
+ it 'is installed' do
+ subject.make_externally_installed
+
+ expect(subject).to be_installed
+ end
+ end
+ end
+
+ describe '#make_externally_uninstalled' do
+ subject { create(application_name, :installed) }
+
+ it 'is uninstalled' do
+ subject.make_externally_uninstalled
+
+ expect(subject).to be_uninstalled
+ end
+
+ context 'application is updated' do
+ subject { create(application_name, :updated) }
+
+ it 'is uninstalled' do
+ subject.make_externally_uninstalled
+
+ expect(subject).to be_uninstalled
+ end
+ end
+
+ context 'application is errored' do
+ subject { create(application_name, :errored) }
+
+ it 'is uninstalled' do
+ subject.make_externally_uninstalled
+
+ expect(subject).to be_uninstalled
+ end
+ end
+ end
+
describe '#make_scheduled' do
subject { create(application_name, :installable) }
@@ -278,6 +338,7 @@ RSpec.shared_examples 'cluster application status specs' do |application_name|
:update_errored | false
:uninstalling | false
:uninstall_errored | false
+ :uninstalled | false
:timed_out | false
end
diff --git a/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb b/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb
index 8372ee9ac4a..76339837351 100644
--- a/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb
@@ -76,7 +76,7 @@ RSpec.shared_examples 'a blob replicator' do
expect(service).to receive(:execute)
expect(::Geo::BlobDownloadService).to receive(:new).with(replicator: replicator).and_return(service)
- replicator.consume_created_event
+ replicator.consume_event_created
end
end
@@ -89,7 +89,7 @@ RSpec.shared_examples 'a blob replicator' do
end
describe '#model' do
- let(:invoke_model) { replicator.send(:model) }
+ let(:invoke_model) { replicator.class.model }
it 'is implemented' do
expect do
diff --git a/spec/support/shared_examples/models/concerns/has_repository_shared_examples.rb b/spec/support/shared_examples/models/concerns/has_repository_shared_examples.rb
index 41de499f590..30c8c7d0fe5 100644
--- a/spec/support/shared_examples/models/concerns/has_repository_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/has_repository_shared_examples.rb
@@ -5,6 +5,7 @@ RSpec.shared_examples 'model with repository' do
let(:stubbed_container) { raise NotImplementedError }
let(:expected_full_path) { raise NotImplementedError }
let(:expected_web_url_path) { expected_full_path }
+ let(:expected_repo_url_path) { expected_full_path }
describe '#commits_by' do
let(:commits) { container.repository.commits('HEAD', limit: 3).commits }
@@ -53,19 +54,19 @@ RSpec.shared_examples 'model with repository' do
describe '#url_to_repo' do
it 'returns the SSH URL to the repository' do
- expect(container.url_to_repo).to eq("#{Gitlab.config.gitlab_shell.ssh_path_prefix}#{expected_web_url_path}.git")
+ expect(container.url_to_repo).to eq(container.ssh_url_to_repo)
end
end
describe '#ssh_url_to_repo' do
it 'returns the SSH URL to the repository' do
- expect(container.ssh_url_to_repo).to eq(container.url_to_repo)
+ expect(container.ssh_url_to_repo).to eq("#{Gitlab.config.gitlab_shell.ssh_path_prefix}#{expected_repo_url_path}.git")
end
end
describe '#http_url_to_repo' do
it 'returns the HTTP URL to the repository' do
- expect(container.http_url_to_repo).to eq("#{Gitlab.config.gitlab.url}/#{expected_web_url_path}.git")
+ expect(container.http_url_to_repo).to eq("#{Gitlab.config.gitlab.url}/#{expected_repo_url_path}.git")
end
end
@@ -95,12 +96,8 @@ RSpec.shared_examples 'model with repository' do
end
context 'when the repo exists' do
- it { expect(container.empty_repo?).to be(false) }
-
- it 'returns true when repository is empty' do
- allow(container.repository).to receive(:empty?).and_return(true)
-
- expect(container.empty_repo?).to be(true)
+ it 'returns the empty state of the repository' do
+ expect(container.empty_repo?).to be(container.repository.empty?)
end
end
end
@@ -146,15 +143,14 @@ RSpec.shared_examples 'model with repository' do
end
it 'picks storage from ApplicationSetting' do
- expect_next_instance_of(ApplicationSetting) do |instance|
- expect(instance).to receive(:pick_repository_storage).and_return('picked')
- end
+ expect(Gitlab::CurrentSettings).to receive(:pick_repository_storage).and_return('picked')
expect(subject).to eq('picked')
end
it 'picks from the latest available storage', :request_store do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
+ Gitlab::CurrentSettings.expire_current_application_settings
Gitlab::CurrentSettings.current_application_settings
settings = ApplicationSetting.last
diff --git a/spec/support/shared_examples/models/concerns/has_wiki_shared_examples.rb b/spec/support/shared_examples/models/concerns/has_wiki_shared_examples.rb
new file mode 100644
index 00000000000..0357b7462fb
--- /dev/null
+++ b/spec/support/shared_examples/models/concerns/has_wiki_shared_examples.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'model with wiki' do
+ describe '#create_wiki' do
+ it 'returns true if the wiki repository already exists' do
+ expect(container.wiki_repository_exists?).to be(true)
+ expect(container.create_wiki).to be(true)
+ end
+
+ it 'returns true if the wiki repository was created' do
+ expect(container_without_wiki.wiki_repository_exists?).to be(false)
+ expect(container_without_wiki.create_wiki).to be(true)
+ expect(container_without_wiki.wiki_repository_exists?).to be(true)
+ end
+
+ context 'when the repository cannot be created' do
+ before do
+ expect(container.wiki).to receive(:wiki) { raise Wiki::CouldNotCreateWikiError }
+ end
+
+ it 'returns false and adds a validation error' do
+ expect(container.create_wiki).to be(false)
+ expect(container.errors[:base]).to contain_exactly('Failed to create wiki')
+ end
+ end
+ end
+
+ describe '#wiki_repository_exists?' do
+ it 'returns true when the wiki repository exists' do
+ expect(container.wiki_repository_exists?).to eq(true)
+ end
+
+ it 'returns false when the wiki repository does not exist' do
+ expect(container_without_wiki.wiki_repository_exists?).to eq(false)
+ end
+ end
+
+ describe 'wiki path conflict' do
+ context 'when the new path has been used by the wiki of other Project' do
+ it 'has an error on the name attribute' do
+ create(:project, namespace: container.parent, path: 'existing')
+ container.path = 'existing.wiki'
+
+ expect(container).not_to be_valid
+ expect(container.errors[:name].first).to eq(_('has already been taken'))
+ end
+ end
+
+ context 'when the new wiki path has been used by the path of other Project' do
+ it 'has an error on the name attribute' do
+ create(:project, namespace: container.parent, path: 'existing.wiki')
+ container.path = 'existing'
+
+ expect(container).not_to be_valid
+ expect(container.errors[:name].first).to eq(_('has already been taken'))
+ end
+ end
+
+ context 'when the new path has been used by the wiki of other Group' do
+ it 'has an error on the name attribute' do
+ create(:group, parent: container.parent, path: 'existing')
+ container.path = 'existing.wiki'
+
+ expect(container).not_to be_valid
+ expect(container.errors[:name].first).to eq(_('has already been taken'))
+ end
+ end
+
+ context 'when the new wiki path has been used by the path of other Group' do
+ it 'has an error on the name attribute' do
+ create(:group, parent: container.parent, path: 'existing.wiki')
+ container.path = 'existing'
+
+ expect(container).not_to be_valid
+ expect(container.errors[:name].first).to eq(_('has already been taken'))
+ end
+ 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
new file mode 100644
index 00000000000..4bcea36fd42
--- /dev/null
+++ b/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'includes Limitable concern' do
+ describe 'validations' do
+ let(:plan_limits) { create(:plan_limits, :default_plan) }
+
+ it { is_expected.to be_a(Limitable) }
+
+ context 'without plan limits configured' do
+ it 'can create new models' do
+ 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)
+ end
+
+ it 'can create new models' do
+ expect { subject.save }.to change { described_class.count }
+ end
+
+ context 'with an existing model' do
+ before do
+ subject.dup.save
+ end
+
+ it 'cannot create new models exceding the plan limits' do
+ expect { subject.save }.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
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb
new file mode 100644
index 00000000000..32d502af5a2
--- /dev/null
+++ b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb
@@ -0,0 +1,242 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'a timebox' do |timebox_type|
+ let(:project) { create(:project, :public) }
+ let(:group) { create(:group) }
+ let(:timebox) { create(timebox_type, project: project) }
+ let(:issue) { create(:issue, project: project) }
+ let(:user) { create(:user) }
+ let(:timebox_table_name) { timebox_type.to_s.pluralize.to_sym }
+
+ describe 'modules' do
+ context 'with a project' do
+ it_behaves_like 'AtomicInternalId' do
+ let(:internal_id_attribute) { :iid }
+ let(:instance) { build(timebox_type, project: build(:project), group: nil) }
+ let(:scope) { :project }
+ let(:scope_attrs) { { project: instance.project } }
+ let(:usage) { timebox_table_name }
+ end
+ end
+
+ context 'with a group' do
+ it_behaves_like 'AtomicInternalId' do
+ let(:internal_id_attribute) { :iid }
+ let(:instance) { build(timebox_type, project: nil, group: build(:group)) }
+ let(:scope) { :group }
+ let(:scope_attrs) { { namespace: instance.group } }
+ let(:usage) { timebox_table_name }
+ end
+ end
+ end
+
+ describe "Validation" do
+ before do
+ allow(subject).to receive(:set_iid).and_return(false)
+ end
+
+ describe 'start_date' do
+ it 'adds an error when start_date is greater then due_date' do
+ timebox = build(timebox_type, start_date: Date.tomorrow, due_date: Date.yesterday)
+
+ expect(timebox).not_to be_valid
+ expect(timebox.errors[:due_date]).to include("must be greater than start date")
+ end
+
+ it 'adds an error when start_date is greater than 9999-12-31' do
+ timebox = build(timebox_type, start_date: Date.new(10000, 1, 1))
+
+ expect(timebox).not_to be_valid
+ expect(timebox.errors[:start_date]).to include("date must not be after 9999-12-31")
+ end
+ end
+
+ describe 'due_date' do
+ it 'adds an error when due_date is greater than 9999-12-31' do
+ timebox = build(timebox_type, due_date: Date.new(10000, 1, 1))
+
+ expect(timebox).not_to be_valid
+ expect(timebox.errors[:due_date]).to include("date must not be after 9999-12-31")
+ end
+ end
+
+ describe 'title' do
+ it { is_expected.to validate_presence_of(:title) }
+
+ it 'is invalid if title would be empty after sanitation' do
+ timebox = build(timebox_type, project: project, title: '<img src=x onerror=prompt(1)>')
+
+ expect(timebox).not_to be_valid
+ expect(timebox.errors[:title]).to include("can't be blank")
+ end
+ end
+
+ describe '#timebox_type_check' do
+ it 'is invalid if it has both project_id and group_id' do
+ timebox = build(timebox_type, group: group)
+ timebox.project = project
+
+ expect(timebox).not_to be_valid
+ expect(timebox.errors[:project_id]).to include("#{timebox_type} should belong either to a project or a group.")
+ end
+ end
+
+ describe "#uniqueness_of_title" do
+ context "per project" do
+ it "does not accept the same title in a project twice" do
+ new_timebox = timebox.dup
+ expect(new_timebox).not_to be_valid
+ end
+
+ it "accepts the same title in another project" do
+ project = create(:project)
+ new_timebox = timebox.dup
+ new_timebox.project = project
+
+ expect(new_timebox).to be_valid
+ end
+ end
+
+ context "per group" do
+ let(:timebox) { create(timebox_type, group: group) }
+
+ before do
+ project.update(group: group)
+ end
+
+ it "does not accept the same title in a group twice" do
+ new_timebox = described_class.new(group: group, title: timebox.title)
+
+ expect(new_timebox).not_to be_valid
+ end
+
+ it "does not accept the same title of a child project timebox" do
+ create(timebox_type, project: group.projects.first)
+
+ new_timebox = described_class.new(group: group, title: timebox.title)
+
+ expect(new_timebox).not_to be_valid
+ end
+ end
+ end
+ end
+
+ describe "Associations" do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:group) }
+ it { is_expected.to have_many(:issues) }
+ it { is_expected.to have_many(:merge_requests) }
+ it { is_expected.to have_many(:labels) }
+ end
+
+ describe '#timebox_name' do
+ it 'returns the name of the model' do
+ expect(timebox.timebox_name).to eq(timebox_type.to_s)
+ end
+ end
+
+ describe '#project_timebox?' do
+ context 'when project_id is present' do
+ it 'returns true' do
+ expect(timebox.project_timebox?).to be_truthy
+ end
+ end
+
+ context 'when project_id is not present' do
+ let(:timebox) { build(timebox_type, group: group) }
+
+ it 'returns false' do
+ expect(timebox.project_timebox?).to be_falsey
+ end
+ end
+ end
+
+ describe '#group_timebox?' do
+ context 'when group_id is present' do
+ let(:timebox) { build(timebox_type, group: group) }
+
+ it 'returns true' do
+ expect(timebox.group_timebox?).to be_truthy
+ end
+ end
+
+ context 'when group_id is not present' do
+ it 'returns false' do
+ expect(timebox.group_timebox?).to be_falsey
+ end
+ end
+ end
+
+ describe '#safe_title' do
+ let(:timebox) { create(timebox_type, title: "<b>foo & bar -> 2.2</b>") }
+
+ it 'normalizes the title for use as a slug' do
+ expect(timebox.safe_title).to eq('foo-bar-22')
+ end
+ end
+
+ describe '#resource_parent' do
+ context 'when group is present' do
+ let(:timebox) { build(timebox_type, group: group) }
+
+ it 'returns the group' do
+ expect(timebox.resource_parent).to eq(group)
+ end
+ end
+
+ context 'when project is present' do
+ it 'returns the project' do
+ expect(timebox.resource_parent).to eq(project)
+ end
+ end
+ end
+
+ describe "#title" do
+ let(:timebox) { create(timebox_type, title: "<b>foo & bar -> 2.2</b>") }
+
+ it "sanitizes title" do
+ expect(timebox.title).to eq("foo & bar -> 2.2")
+ end
+ end
+
+ describe '#merge_requests_enabled?' do
+ context "per project" do
+ it "is true for projects with MRs enabled" do
+ project = create(:project, :merge_requests_enabled)
+ timebox = create(timebox_type, project: project)
+
+ expect(timebox.merge_requests_enabled?).to be_truthy
+ end
+
+ it "is false for projects with MRs disabled" do
+ project = create(:project, :repository_enabled, :merge_requests_disabled)
+ timebox = create(timebox_type, project: project)
+
+ expect(timebox.merge_requests_enabled?).to be_falsey
+ end
+
+ it "is false for projects with repository disabled" do
+ project = create(:project, :repository_disabled)
+ timebox = create(timebox_type, project: project)
+
+ expect(timebox.merge_requests_enabled?).to be_falsey
+ end
+ end
+
+ context "per group" do
+ let(:timebox) { create(timebox_type, group: group) }
+
+ it "is always true for groups, for performance reasons" do
+ expect(timebox.merge_requests_enabled?).to be_truthy
+ end
+ end
+ end
+
+ describe '#to_ability_name' do
+ it 'returns timebox' do
+ timebox = build(timebox_type)
+
+ expect(timebox.to_ability_name).to eq(timebox_type.to_s)
+ 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 aa8979603b6..b0cdc77a378 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
@@ -49,5 +49,29 @@ RSpec.shared_examples 'a valid diff positionable note' do |factory_on_commit|
expect(subject.errors).to have_key(:commit_id)
end
end
+
+ %i(original_position position change_position).each do |method|
+ describe "#{method}=" do
+ it "doesn't accept non-hash JSON passed as a string" do
+ subject.send(:"#{method}=", "true")
+ expect(subject.attributes_before_type_cast[method.to_s]).to be(nil)
+ end
+
+ it "does accept a position hash as a string" do
+ subject.send(:"#{method}=", position.to_json)
+ expect(subject.position).to eq(position)
+ end
+
+ it "doesn't accept an array" do
+ subject.send(:"#{method}=", ["test"])
+ expect(subject.attributes_before_type_cast[method.to_s]).to be(nil)
+ end
+
+ it "does accept a hash" do
+ subject.send(:"#{method}=", position.to_h)
+ expect(subject.position).to eq(position)
+ end
+ end
+ end
end
end
diff --git a/spec/support/shared_examples/models/email_format_shared_examples.rb b/spec/support/shared_examples/models/email_format_shared_examples.rb
index 6797836e383..a8115e440a4 100644
--- a/spec/support/shared_examples/models/email_format_shared_examples.rb
+++ b/spec/support/shared_examples/models/email_format_shared_examples.rb
@@ -44,3 +44,44 @@ RSpec.shared_examples 'an object with email-formated attributes' do |*attributes
end
end
end
+
+RSpec.shared_examples 'an object with RFC3696 compliant email-formated attributes' do |*attributes|
+ attributes.each do |attribute|
+ describe "specifically its :#{attribute} attribute" do
+ %w[
+ info@example.com
+ info+test@example.com
+ o'reilly@example.com
+ ].each do |valid_email|
+ context "with a value of '#{valid_email}'" do
+ let(:email_value) { valid_email }
+
+ it 'is valid' do
+ subject.send("#{attribute}=", valid_email)
+
+ expect(subject).to be_valid
+ end
+ end
+ end
+
+ %w[
+ foobar
+ test@test@example.com
+ test.test.@example.com
+ .test.test@example.com
+ mailto:test@example.com
+ lol!'+=?><#$%^&*()@gmail.com
+ ].each do |invalid_email|
+ context "with a value of '#{invalid_email}'" do
+ let(:email_value) { invalid_email }
+
+ it 'is invalid' do
+ subject.send("#{attribute}=", invalid_email)
+
+ expect(subject).to be_invalid
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb b/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb
index ecf1640ef5d..21ab9b06c33 100644
--- a/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb
+++ b/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb
@@ -7,6 +7,7 @@ RSpec.shared_examples 'issuable hook data' do |kind|
include_examples 'project hook data' do
let(:project) { builder.issuable.project }
end
+
include_examples 'deprecated repository hook data'
context "with a #{kind}" do
diff --git a/spec/support/shared_examples/models/mentionable_shared_examples.rb b/spec/support/shared_examples/models/mentionable_shared_examples.rb
index ba6aa4e1d89..d3e9035393f 100644
--- a/spec/support/shared_examples/models/mentionable_shared_examples.rb
+++ b/spec/support/shared_examples/models/mentionable_shared_examples.rb
@@ -210,6 +210,10 @@ RSpec.shared_examples 'mentions in description' do |mentionable_type|
it 'stores no mentions' do
expect(mentionable.user_mentions.count).to eq 0
end
+
+ it 'renders description_html correctly' do
+ expect(mentionable.description_html).to include("<a href=\"/#{user.username}\" data-user=\"#{user.id}\"")
+ end
end
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 24ff57c8517..a5228c43f6f 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
@@ -112,7 +112,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
message: "user created page: Awesome wiki_page"
}
- @wiki_page = create(:wiki_page, wiki: project.wiki, attrs: opts)
+ @wiki_page = create(:wiki_page, wiki: project.wiki, **opts)
@wiki_page_sample_data = Gitlab::DataBuilder::WikiPage.build(@wiki_page, user, 'create')
end
diff --git a/spec/support/shared_examples/models/wiki_shared_examples.rb b/spec/support/shared_examples/models/wiki_shared_examples.rb
new file mode 100644
index 00000000000..84569e95e11
--- /dev/null
+++ b/spec/support/shared_examples/models/wiki_shared_examples.rb
@@ -0,0 +1,423 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'wiki model' do
+ let_it_be(:user) { create(:user, :commit_email) }
+ let(:wiki_container) { raise NotImplementedError }
+ let(:wiki_container_without_repo) { raise NotImplementedError }
+ let(:wiki) { described_class.new(wiki_container, user) }
+ let(:commit) { subject.repository.head_commit }
+
+ subject { wiki }
+
+ it_behaves_like 'model with repository' do
+ let(:container) { wiki }
+ let(:stubbed_container) { described_class.new(wiki_container_without_repo, user) }
+ let(:expected_full_path) { "#{container.container.full_path}.wiki" }
+ let(:expected_web_url_path) { "#{container.container.web_url(only_path: true).sub(%r{^/}, '')}/-/wikis/home" }
+ end
+
+ describe '#repository' do
+ it 'returns a wiki repository' do
+ expect(subject.repository.repo_type).to be_wiki
+ end
+ end
+
+ describe '#full_path' do
+ it 'returns the container path with the .wiki extension' do
+ expect(subject.full_path).to eq(wiki_container.full_path + '.wiki')
+ end
+ end
+
+ describe '#wiki_base_path' do
+ it 'returns the wiki base path' do
+ expect(subject.wiki_base_path).to eq("#{wiki_container.web_url(only_path: true)}/-/wikis")
+ end
+ end
+
+ describe '#wiki' do
+ it 'contains a Gitlab::Git::Wiki instance' do
+ expect(subject.wiki).to be_a Gitlab::Git::Wiki
+ end
+
+ it 'creates a new wiki repo if one does not yet exist' do
+ expect(subject.create_page('index', 'test content')).to be_truthy
+ end
+
+ it 'creates a new wiki repo with a default commit message' do
+ expect(subject.create_page('index', 'test content', :markdown, '')).to be_truthy
+
+ page = subject.find_page('index')
+
+ expect(page.last_version.message).to eq("#{user.username} created page: index")
+ end
+
+ context 'when the repository cannot be created' do
+ let(:wiki_container) { wiki_container_without_repo }
+
+ before do
+ expect(subject.repository).to receive(:create_if_not_exists) { false }
+ end
+
+ it 'raises CouldNotCreateWikiError' do
+ expect { subject.wiki }.to raise_exception(Wiki::CouldNotCreateWikiError)
+ end
+ end
+ end
+
+ describe '#empty?' do
+ context 'when the wiki repository is empty' do
+ it 'returns true' do
+ expect(subject.empty?).to be(true)
+ end
+ end
+
+ context 'when the wiki has pages' do
+ before do
+ subject.create_page('index', 'This is an awesome new Gollum Wiki')
+ subject.create_page('another-page', 'This is another page')
+ end
+
+ describe '#empty?' do
+ it 'returns false' do
+ expect(subject.empty?).to be(false)
+ end
+
+ it 'only instantiates a Wiki page once' do
+ expect(WikiPage).to receive(:new).once.and_call_original
+
+ subject.empty?
+ end
+ end
+ end
+ end
+
+ describe '#list_pages' do
+ let(:wiki_pages) { subject.list_pages }
+
+ before do
+ subject.create_page('index', 'This is an index')
+ subject.create_page('index2', 'This is an index2')
+ subject.create_page('an index3', 'This is an index3')
+ end
+
+ it 'returns an array of WikiPage instances' do
+ expect(wiki_pages).to be_present
+ expect(wiki_pages).to all(be_a(WikiPage))
+ end
+
+ it 'does not load WikiPage content by default' do
+ wiki_pages.each do |page|
+ expect(page.content).to be_empty
+ end
+ end
+
+ it 'returns all pages by default' do
+ expect(wiki_pages.count).to eq(3)
+ end
+
+ context 'with limit option' do
+ it 'returns limited set of pages' do
+ expect(subject.list_pages(limit: 1).count).to eq(1)
+ end
+ end
+
+ context 'with sorting options' do
+ it 'returns pages sorted by title by default' do
+ pages = ['an index3', 'index', 'index2']
+
+ expect(subject.list_pages.map(&:title)).to eq(pages)
+ expect(subject.list_pages(direction: 'desc').map(&:title)).to eq(pages.reverse)
+ end
+
+ it 'returns pages sorted by created_at' do
+ pages = ['index', 'index2', 'an index3']
+
+ expect(subject.list_pages(sort: 'created_at').map(&:title)).to eq(pages)
+ expect(subject.list_pages(sort: 'created_at', direction: 'desc').map(&:title)).to eq(pages.reverse)
+ end
+ end
+
+ context 'with load_content option' do
+ let(:pages) { subject.list_pages(load_content: true) }
+
+ it 'loads WikiPage content' do
+ expect(pages.first.content).to eq('This is an index3')
+ expect(pages.second.content).to eq('This is an index')
+ expect(pages.third.content).to eq('This is an index2')
+ end
+ end
+ end
+
+ describe '#sidebar_entries' do
+ before do
+ (1..5).each { |i| create(:wiki_page, wiki: wiki, title: "my page #{i}") }
+ (6..10).each { |i| create(:wiki_page, wiki: wiki, title: "parent/my page #{i}") }
+ (11..15).each { |i| create(:wiki_page, wiki: wiki, title: "grandparent/parent/my page #{i}") }
+ end
+
+ def total_pages(entries)
+ entries.sum do |entry|
+ entry.is_a?(WikiDirectory) ? entry.pages.size : 1
+ end
+ end
+
+ context 'when the number of pages does not exceed the limit' do
+ it 'returns all pages grouped by directory and limited is false' do
+ entries, limited = subject.sidebar_entries
+
+ expect(entries.size).to be(7)
+ expect(total_pages(entries)).to be(15)
+ expect(limited).to be(false)
+ end
+ end
+
+ context 'when the number of pages exceeds the limit' do
+ before do
+ create(:wiki_page, wiki: wiki, title: 'my page 16')
+ end
+
+ it 'returns 15 pages grouped by directory and limited is true' do
+ entries, limited = subject.sidebar_entries
+
+ expect(entries.size).to be(8)
+ expect(total_pages(entries)).to be(15)
+ expect(limited).to be(true)
+ end
+ end
+ end
+
+ describe '#find_page' do
+ before do
+ subject.create_page('index page', 'This is an awesome Gollum Wiki')
+ end
+
+ it 'returns the latest version of the page if it exists' do
+ page = subject.find_page('index page')
+
+ expect(page.title).to eq('index page')
+ end
+
+ it 'returns nil if the page does not exist' do
+ expect(subject.find_page('non-existent')).to eq(nil)
+ end
+
+ it 'can find a page by slug' do
+ page = subject.find_page('index-page')
+
+ expect(page.title).to eq('index page')
+ end
+
+ it 'returns a WikiPage instance' do
+ page = subject.find_page('index page')
+
+ expect(page).to be_a WikiPage
+ end
+
+ context 'pages with multibyte-character title' do
+ before do
+ subject.create_page('autre pagé', "C'est un génial Gollum Wiki")
+ end
+
+ it 'can find a page by slug' do
+ page = subject.find_page('autre pagé')
+
+ expect(page.title).to eq('autre pagé')
+ end
+ end
+
+ context 'pages with invalidly-encoded content' do
+ before do
+ subject.create_page('encoding is fun', "f\xFCr".b)
+ end
+
+ it 'can find the page' do
+ page = subject.find_page('encoding is fun')
+
+ expect(page.content).to eq('fr')
+ end
+ end
+ end
+
+ describe '#find_sidebar' do
+ before do
+ subject.create_page(described_class::SIDEBAR, 'This is an awesome Sidebar')
+ end
+
+ it 'finds the page defined as _sidebar' do
+ page = subject.find_sidebar
+
+ expect(page.content).to eq('This is an awesome Sidebar')
+ end
+ end
+
+ describe '#find_file' do
+ let(:image) { File.open(Rails.root.join('spec', 'fixtures', 'big-image.png')) }
+
+ before do
+ subject.wiki # Make sure the wiki repo exists
+
+ subject.repository.create_file(user, 'image.png', image, branch_name: subject.default_branch, message: 'add image')
+ end
+
+ it 'returns the latest version of the file if it exists' do
+ file = subject.find_file('image.png')
+
+ expect(file.mime_type).to eq('image/png')
+ end
+
+ it 'returns nil if the page does not exist' do
+ expect(subject.find_file('non-existent')).to eq(nil)
+ end
+
+ it 'returns a Gitlab::Git::WikiFile instance' do
+ file = subject.find_file('image.png')
+
+ expect(file).to be_a Gitlab::Git::WikiFile
+ end
+
+ it 'returns the whole file' do
+ file = subject.find_file('image.png')
+ image.rewind
+
+ expect(file.raw_data.b).to eq(image.read.b)
+ end
+ end
+
+ describe '#create_page' do
+ it 'creates a new wiki page' do
+ expect(subject.create_page('test page', 'this is content')).not_to eq(false)
+ expect(subject.list_pages.count).to eq(1)
+ end
+
+ it 'returns false when a duplicate page exists' do
+ subject.create_page('test page', 'content')
+
+ expect(subject.create_page('test page', 'content')).to eq(false)
+ end
+
+ it 'stores an error message when a duplicate page exists' do
+ 2.times { subject.create_page('test page', 'content') }
+
+ expect(subject.error_message).to match(/Duplicate page:/)
+ end
+
+ it 'sets the correct commit message' do
+ subject.create_page('test page', 'some content', :markdown, 'commit message')
+
+ expect(subject.list_pages.first.page.version.message).to eq('commit message')
+ end
+
+ it 'sets the correct commit email' do
+ subject.create_page('test page', 'content')
+
+ expect(user.commit_email).not_to eq(user.email)
+ expect(commit.author_email).to eq(user.commit_email)
+ expect(commit.committer_email).to eq(user.commit_email)
+ end
+
+ it 'updates container activity' do
+ expect(subject).to receive(:update_container_activity)
+
+ subject.create_page('Test Page', 'This is content')
+ end
+ end
+
+ describe '#update_page' do
+ let(:page) { create(:wiki_page, wiki: subject, title: 'update-page') }
+
+ def update_page
+ subject.update_page(
+ page.page,
+ content: 'some other content',
+ format: :markdown,
+ message: 'updated page'
+ )
+ end
+
+ it 'updates the content of the page' do
+ update_page
+ page = subject.find_page('update-page')
+
+ expect(page.raw_content).to eq('some other content')
+ end
+
+ it 'sets the correct commit message' do
+ update_page
+ page = subject.find_page('update-page')
+
+ expect(page.version.message).to eq('updated page')
+ end
+
+ it 'sets the correct commit email' do
+ update_page
+
+ expect(user.commit_email).not_to eq(user.email)
+ expect(commit.author_email).to eq(user.commit_email)
+ expect(commit.committer_email).to eq(user.commit_email)
+ end
+
+ it 'updates container activity' do
+ page
+
+ expect(subject).to receive(:update_container_activity)
+
+ update_page
+ end
+ end
+
+ describe '#delete_page' do
+ let(:page) { create(:wiki_page, wiki: wiki) }
+
+ it 'deletes the page' do
+ subject.delete_page(page)
+
+ expect(subject.list_pages.count).to eq(0)
+ end
+
+ it 'sets the correct commit email' do
+ subject.delete_page(page)
+
+ expect(user.commit_email).not_to eq(user.email)
+ expect(commit.author_email).to eq(user.commit_email)
+ expect(commit.committer_email).to eq(user.commit_email)
+ end
+
+ it 'updates container activity' do
+ page
+
+ expect(subject).to receive(:update_container_activity)
+
+ subject.delete_page(page)
+ end
+ end
+
+ describe '#ensure_repository' do
+ context 'if the repository exists' do
+ it 'does not create the repository' do
+ expect(subject.repository.exists?).to eq(true)
+ expect(subject.repository.raw).not_to receive(:create_repository)
+
+ subject.ensure_repository
+ end
+ end
+
+ context 'if the repository does not exist' do
+ let(:wiki_container) { wiki_container_without_repo }
+
+ it 'creates the repository' do
+ expect(subject.repository.exists?).to eq(false)
+
+ subject.ensure_repository
+
+ expect(subject.repository.exists?).to eq(true)
+ end
+ end
+ end
+
+ describe '#hook_attrs' do
+ it 'returns a hash with values' do
+ expect(subject.hook_attrs).to be_a Hash
+ expect(subject.hook_attrs.keys).to contain_exactly(:web_url, :git_ssh_url, :git_http_url, :path_with_namespace, :default_branch)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/policies/project_policy_shared_examples.rb b/spec/support/shared_examples/policies/project_policy_shared_examples.rb
index 1831fc10628..4dd0152e3d1 100644
--- a/spec/support/shared_examples/policies/project_policy_shared_examples.rb
+++ b/spec/support/shared_examples/policies/project_policy_shared_examples.rb
@@ -212,8 +212,8 @@ RSpec.shared_examples 'project policies as owner' do
end
end
-RSpec.shared_examples 'project policies as admin' do
- context 'abilities for non-public projects' do
+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) }
@@ -232,3 +232,13 @@ RSpec.shared_examples 'project policies as admin' do
end
end
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) }
+
+ it { is_expected.to be_banned }
+ end
+end
diff --git a/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb b/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb
index b91500ffd9c..58822f4309b 100644
--- a/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb
+++ b/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb
@@ -1,152 +1,116 @@
# frozen_string_literal: true
RSpec.shared_examples 'model with wiki policies' do
- let(:container) { raise NotImplementedError }
- let(:permissions) { %i(read_wiki create_wiki update_wiki admin_wiki download_wiki_code) }
-
- # TODO: Remove this helper once we implement group features
- # https://gitlab.com/gitlab-org/gitlab/-/issues/208412
- def set_access_level(access_level)
- raise NotImplementedError
- end
-
- subject { described_class.new(owner, container) }
-
- context 'when the feature is disabled' do
- before do
- set_access_level(ProjectFeature::DISABLED)
- end
+ include ProjectHelpers
+ include AdminModeHelper
- it 'does not include the wiki permissions' do
- expect_disallowed(*permissions)
- end
+ let(:container) { raise NotImplementedError }
+ let(:user) { raise NotImplementedError }
- context 'when there is an external wiki' do
- it 'does not include the wiki permissions' do
- allow(container).to receive(:has_external_wiki?).and_return(true)
+ subject { described_class.new(user, container) }
- expect_disallowed(*permissions)
- end
+ let_it_be(:wiki_permissions) do
+ {}.tap do |permissions|
+ permissions[:guest] = %i[read_wiki]
+ permissions[:reporter] = permissions[:guest] + %i[download_wiki_code]
+ permissions[:developer] = permissions[:reporter] + %i[create_wiki]
+ permissions[:maintainer] = permissions[:developer] + %i[admin_wiki]
+ permissions[:all] = permissions[:maintainer]
end
end
- describe 'read_wiki' do
- subject { described_class.new(user, container) }
-
- member_roles = %i[guest developer]
- stranger_roles = %i[anonymous non_member]
-
- user_roles = stranger_roles + member_roles
+ using RSpec::Parameterized::TableSyntax
+
+ where(:container_level, :access_level, :membership, :access) do
+ :public | :enabled | :admin | :all
+ :public | :enabled | :maintainer | :maintainer
+ :public | :enabled | :developer | :developer
+ :public | :enabled | :reporter | :reporter
+ :public | :enabled | :guest | :guest
+ :public | :enabled | :non_member | :guest
+ :public | :enabled | :anonymous | :guest
+
+ :public | :private | :admin | :all
+ :public | :private | :maintainer | :maintainer
+ :public | :private | :developer | :developer
+ :public | :private | :reporter | :reporter
+ :public | :private | :guest | :guest
+ :public | :private | :non_member | nil
+ :public | :private | :anonymous | nil
+
+ :public | :disabled | :admin | nil
+ :public | :disabled | :maintainer | nil
+ :public | :disabled | :developer | nil
+ :public | :disabled | :reporter | nil
+ :public | :disabled | :guest | nil
+ :public | :disabled | :non_member | nil
+ :public | :disabled | :anonymous | nil
+
+ :internal | :enabled | :admin | :all
+ :internal | :enabled | :maintainer | :maintainer
+ :internal | :enabled | :developer | :developer
+ :internal | :enabled | :reporter | :reporter
+ :internal | :enabled | :guest | :guest
+ :internal | :enabled | :non_member | :guest
+ :internal | :enabled | :anonymous | nil
+
+ :internal | :private | :admin | :all
+ :internal | :private | :maintainer | :maintainer
+ :internal | :private | :developer | :developer
+ :internal | :private | :reporter | :reporter
+ :internal | :private | :guest | :guest
+ :internal | :private | :non_member | nil
+ :internal | :private | :anonymous | nil
+
+ :internal | :disabled | :admin | nil
+ :internal | :disabled | :maintainer | nil
+ :internal | :disabled | :developer | nil
+ :internal | :disabled | :reporter | nil
+ :internal | :disabled | :guest | nil
+ :internal | :disabled | :non_member | nil
+ :internal | :disabled | :anonymous | nil
+
+ :private | :private | :admin | :all
+ :private | :private | :maintainer | :maintainer
+ :private | :private | :developer | :developer
+ :private | :private | :reporter | :reporter
+ :private | :private | :guest | :guest
+ :private | :private | :non_member | nil
+ :private | :private | :anonymous | nil
+
+ :private | :disabled | :admin | nil
+ :private | :disabled | :maintainer | nil
+ :private | :disabled | :developer | nil
+ :private | :disabled | :reporter | nil
+ :private | :disabled | :guest | nil
+ :private | :disabled | :non_member | nil
+ :private | :disabled | :anonymous | nil
+ end
- # When a user is anonymous, their `current_user == nil`
- let(:user) { create(:user) unless user_role == :anonymous }
+ with_them do
+ let(:user) { create_user_from_membership(container, membership) }
+ let(:allowed_permissions) { wiki_permissions[access].dup || [] }
+ let(:disallowed_permissions) { wiki_permissions[:all] - allowed_permissions }
before do
- container.visibility = container_visibility
- set_access_level(wiki_access_level)
- container.add_user(user, user_role) if member_roles.include?(user_role)
- end
-
- title = ->(container_visibility, wiki_access_level, user_role) do
- [
- "container is #{Gitlab::VisibilityLevel.level_name container_visibility}",
- "wiki is #{ProjectFeature.str_from_access_level wiki_access_level}",
- "user is #{user_role}"
- ].join(', ')
- end
-
- describe 'Situations where :read_wiki is always false' do
- where(case_names: title,
- container_visibility: Gitlab::VisibilityLevel.options.values,
- wiki_access_level: [ProjectFeature::DISABLED],
- user_role: user_roles)
-
- with_them do
- it { is_expected.to be_disallowed(:read_wiki) }
- end
- end
-
- describe 'Situations where :read_wiki is always true' do
- where(case_names: title,
- container_visibility: [Gitlab::VisibilityLevel::PUBLIC],
- wiki_access_level: [ProjectFeature::ENABLED],
- user_role: user_roles)
+ container.visibility = container_level.to_s
+ set_access_level(ProjectFeature.access_level_from_str(access_level.to_s))
+ enable_admin_mode!(user) if user&.admin?
- with_them do
- it { is_expected.to be_allowed(:read_wiki) }
+ if allowed_permissions.any? && [container_level, access_level, membership] != [:private, :private, :guest]
+ allowed_permissions << :download_wiki_code
end
end
- describe 'Situations where :read_wiki requires membership' do
- context 'the wiki is private, and the user is a member' do
- where(case_names: title,
- container_visibility: [Gitlab::VisibilityLevel::PUBLIC,
- Gitlab::VisibilityLevel::INTERNAL],
- wiki_access_level: [ProjectFeature::PRIVATE],
- user_role: member_roles)
-
- with_them do
- it { is_expected.to be_allowed(:read_wiki) }
- end
- end
-
- context 'the wiki is private, and the user is not member' do
- where(case_names: title,
- container_visibility: [Gitlab::VisibilityLevel::PUBLIC,
- Gitlab::VisibilityLevel::INTERNAL],
- wiki_access_level: [ProjectFeature::PRIVATE],
- user_role: stranger_roles)
-
- with_them do
- it { is_expected.to be_disallowed(:read_wiki) }
- end
- end
-
- context 'the wiki is enabled, and the user is a member' do
- where(case_names: title,
- container_visibility: [Gitlab::VisibilityLevel::PRIVATE],
- wiki_access_level: [ProjectFeature::ENABLED],
- user_role: member_roles)
-
- with_them do
- it { is_expected.to be_allowed(:read_wiki) }
- end
- end
-
- context 'the wiki is enabled, and the user is not a member' do
- where(case_names: title,
- container_visibility: [Gitlab::VisibilityLevel::PRIVATE],
- wiki_access_level: [ProjectFeature::ENABLED],
- user_role: stranger_roles)
-
- with_them do
- it { is_expected.to be_disallowed(:read_wiki) }
- end
- end
+ it 'allows actions based on membership' do
+ expect_allowed(*allowed_permissions)
+ expect_disallowed(*disallowed_permissions)
end
+ end
- describe 'Situations where :read_wiki prohibits anonymous access' do
- context 'the user is not anonymous' do
- where(case_names: title,
- container_visibility: [Gitlab::VisibilityLevel::INTERNAL],
- wiki_access_level: [ProjectFeature::ENABLED, ProjectFeature::PUBLIC],
- user_role: user_roles.reject { |u| u == :anonymous })
-
- with_them do
- it { is_expected.to be_allowed(:read_wiki) }
- end
- end
-
- context 'the user is anonymous' do
- where(case_names: title,
- container_visibility: [Gitlab::VisibilityLevel::INTERNAL],
- wiki_access_level: [ProjectFeature::ENABLED, ProjectFeature::PUBLIC],
- user_role: %i[anonymous])
-
- with_them do
- it { is_expected.to be_disallowed(:read_wiki) }
- end
- end
- end
+ # TODO: Remove this helper once we implement group features
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/208412
+ def set_access_level(access_level)
+ raise NotImplementedError
end
end
diff --git a/spec/support/shared_examples/quick_actions/issuable/issuable_quick_actions_shared_examples.rb b/spec/support/shared_examples/quick_actions/issuable/issuable_quick_actions_shared_examples.rb
index b03da4471bc..50a8b81b518 100644
--- a/spec/support/shared_examples/quick_actions/issuable/issuable_quick_actions_shared_examples.rb
+++ b/spec/support/shared_examples/quick_actions/issuable/issuable_quick_actions_shared_examples.rb
@@ -18,6 +18,16 @@ RSpec.shared_examples 'issuable quick actions' do
end
end
+ let(:unlabel_expectation) do
+ ->(noteable, can_use_quick_action) {
+ if can_use_quick_action
+ expect(noteable.labels).to be_empty
+ else
+ expect(noteable.labels).not_to be_empty
+ end
+ }
+ end
+
# Quick actions shared by issues and merge requests
let(:issuable_quick_actions) do
[
@@ -136,13 +146,11 @@ RSpec.shared_examples 'issuable quick actions' do
),
QuickAction.new(
action_text: "/unlabel",
- expectation: ->(noteable, can_use_quick_action) {
- if can_use_quick_action
- expect(noteable.labels).to be_empty
- else
- expect(noteable.labels).not_to be_empty
- end
- }
+ expectation: unlabel_expectation
+ ),
+ QuickAction.new(
+ action_text: "/remove_label",
+ expectation: unlabel_expectation
),
QuickAction.new(
action_text: "/award :100:",
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 88ad37d232f..9bfd1e6faa0 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
@@ -25,7 +25,7 @@ RSpec.shared_examples 'creating award emojis marks Todos as done' do
let(:awardable) { create(type) }
let!(:todo) { create(:todo, target: awardable, project: project, user: user) }
- it do
+ specify do
subject
expect(todo.reload.done?).to eq(expectation)
diff --git a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb
index 90ac60a6fe7..feb3ba46353 100644
--- a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb
@@ -80,7 +80,7 @@ RSpec.shared_examples 'group and project boards query' do
cursored_query = query("after: \"#{end_cursor}\"")
post_graphql(cursored_query, current_user: current_user)
- response_data = JSON.parse(response.body)['data'][board_parent_type]['boards']['edges']
+ response_data = Gitlab::Json.parse(response.body)['data'][board_parent_type]['boards']['edges']
expect(grab_names(response_data)).to eq expected_boards.drop(2).first(2).map(&:name)
end
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
new file mode 100644
index 00000000000..48824a4b0d2
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/graphql/mutations/snippets_shared_examples.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'when the snippet is not found' do
+ let(:snippet_gid) do
+ "gid://gitlab/#{snippet.class.name}/#{non_existing_record_id}"
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors',
+ errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
+end
diff --git a/spec/support/shared_examples/requests/snippet_shared_examples.rb b/spec/support/shared_examples/requests/snippet_shared_examples.rb
index aa7f57ae903..f830f957174 100644
--- a/spec/support/shared_examples/requests/snippet_shared_examples.rb
+++ b/spec/support/shared_examples/requests/snippet_shared_examples.rb
@@ -23,21 +23,53 @@ RSpec.shared_examples 'update with repository actions' do
context 'when the repository does not exist' do
let(:snippet) { snippet_without_repo }
- it 'creates the repository' do
- update_snippet(snippet_id: snippet.id, params: { title: 'foo' })
+ context 'when update attributes does not include file_name or content' do
+ it 'does not create the repository' do
+ update_snippet(snippet_id: snippet.id, params: { title: 'foo' })
- expect(snippet.repository).to exist
+ expect(snippet.repository).not_to exist
+ end
end
- it 'commits the file to the repository' do
- content = 'New Content'
- file_name = 'file_name.rb'
+ context 'when update attributes include file_name or content' do
+ it 'creates the repository' do
+ update_snippet(snippet_id: snippet.id, params: { title: 'foo', file_name: 'foo' })
+
+ expect(snippet.repository).to exist
+ end
+
+ it 'commits the file to the repository' do
+ content = 'New Content'
+ file_name = 'file_name.rb'
+
+ update_snippet(snippet_id: snippet.id, params: { content: content, file_name: file_name })
+
+ blob = snippet.repository.blob_at('master', file_name)
+ expect(blob).not_to be_nil
+ expect(blob.data).to eq content
+ end
+
+ context 'when save fails due to a repository creation error' do
+ let(:content) { 'File content' }
+ let(:file_name) { 'test.md' }
+
+ before do
+ allow_next_instance_of(Snippets::UpdateService) do |instance|
+ allow(instance).to receive(:create_repository_for).with(snippet).and_raise(Snippets::UpdateService::CreateRepositoryError)
+ end
+
+ update_snippet(snippet_id: snippet.id, params: { content: content, file_name: file_name })
+ end
- update_snippet(snippet_id: snippet.id, params: { content: content, file_name: file_name })
+ it 'returns 400' do
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
- blob = snippet.repository.blob_at('master', file_name)
- expect(blob).not_to be_nil
- expect(blob.data).to eq content
+ it 'does not save the changes to the snippet object' do
+ expect(snippet.content).not_to eq(content)
+ expect(snippet.file_name).not_to eq(file_name)
+ end
+ end
end
end
end
@@ -48,3 +80,21 @@ RSpec.shared_examples 'snippet response without repository URLs' do
expect(json_response).not_to have_key('http_url_to_repo')
end
end
+
+RSpec.shared_examples 'snippet blob content' do
+ it 'returns content from repository' do
+ subject
+
+ expect(response.body).to eq(snippet.blobs.first.data)
+ end
+
+ context 'when snippet repository is empty' do
+ let(:snippet) { snippet_with_empty_repo }
+
+ it 'returns content from database' do
+ subject
+
+ expect(response.body).to eq(snippet.content)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requires_variables_shared_example.rb b/spec/support/shared_examples/requires_variables_shared_example.rb
new file mode 100644
index 00000000000..2921fccf87a
--- /dev/null
+++ b/spec/support/shared_examples/requires_variables_shared_example.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'requires variables' do
+ it 'shared example requires variables to be set', :aggregate_failures do
+ variables = Array.wrap(required_variables)
+
+ variables.each do |variable_name|
+ expect { send(variable_name) }.not_to(
+ raise_error, "The following variable must be set to use this shared example: #{variable_name}"
+ )
+ end
+ end
+end
diff --git a/spec/support/shared_examples/resource_events.rb b/spec/support/shared_examples/resource_events.rb
index 963453666c9..66f5e760c37 100644
--- a/spec/support/shared_examples/resource_events.rb
+++ b/spec/support/shared_examples/resource_events.rb
@@ -83,6 +83,24 @@ shared_examples 'a resource event for issues' do
expect(events).to be_empty
end
end
+
+ describe '.by_issue_ids_and_created_at_earlier_or_equal_to' do
+ let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: '2020-03-10') }
+ let_it_be(:event2) { create(described_class.name.underscore.to_sym, issue: issue2, created_at: '2020-03-10') }
+ let_it_be(:event3) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: '2020-03-12') }
+
+ it 'returns the expected records for an issue with events' do
+ events = described_class.by_issue_ids_and_created_at_earlier_or_equal_to([issue1.id, issue2.id], '2020-03-11 23:59:59')
+
+ expect(events).to contain_exactly(event1, event2)
+ end
+
+ it 'returns the expected records for an issue with no events' do
+ events = described_class.by_issue_ids_and_created_at_earlier_or_equal_to(issue3, '2020-03-12')
+
+ expect(events).to be_empty
+ end
+ end
end
shared_examples 'a resource event for merge requests' do
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 b6c4841dbd4..db5c4b45b70 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
@@ -60,7 +60,7 @@ RSpec.shared_examples 'diff file entity' do
context 'when the `single_mr_diff_view` feature is disabled' do
before do
- stub_feature_flags(single_mr_diff_view: { enabled: false, thing: project })
+ stub_feature_flags(single_mr_diff_view: false)
end
it 'contains both kinds of diffs' do
diff --git a/spec/support/shared_examples/services/boards/lists_list_service_shared_examples.rb b/spec/support/shared_examples/services/boards/lists_list_service_shared_examples.rb
index 1b7fe626aea..07a6353296d 100644
--- a/spec/support/shared_examples/services/boards/lists_list_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/boards/lists_list_service_shared_examples.rb
@@ -18,6 +18,10 @@ RSpec.shared_examples 'lists list service' do
expect { service.execute(board) }.to change(board.lists, :count).by(1)
end
+ it 'does not create a backlog list when create_default_lists is false' do
+ expect { service.execute(board, create_default_lists: false) }.not_to change(board.lists, :count)
+ end
+
it "returns board's lists" do
expect(service.execute(board)).to eq [board.backlog_list, list, board.closed_list]
end
diff --git a/spec/support/shared_examples/services/measurable_service_shared_examples.rb b/spec/support/shared_examples/services/measurable_service_shared_examples.rb
new file mode 100644
index 00000000000..206c25e49af
--- /dev/null
+++ b/spec/support/shared_examples/services/measurable_service_shared_examples.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'measurable service' do
+ context 'when measurement is enabled' do
+ let!(:measuring) { Gitlab::Utils::Measuring.new(base_log_data) }
+
+ before do
+ stub_feature_flags(feature_flag => true)
+ end
+
+ it 'measure service execution with Gitlab::Utils::Measuring', :aggregate_failures do
+ expect(Gitlab::Utils::Measuring).to receive(:new).with(base_log_data).and_return(measuring)
+ expect(measuring).to receive(:with_measuring).and_call_original
+ end
+ end
+
+ context 'when measurement is disabled' do
+ it 'does not measure service execution' do
+ stub_feature_flags(feature_flag => false)
+
+ expect(Gitlab::Utils::Measuring).not_to receive(:new)
+ end
+ end
+
+ def feature_flag
+ "gitlab_service_measuring_#{described_class_name}"
+ end
+
+ def described_class_name
+ described_class.name.underscore.tr('/', '_')
+ end
+end
diff --git a/spec/support/shared_examples/services/metrics/dashboard_shared_examples.rb b/spec/support/shared_examples/services/metrics/dashboard_shared_examples.rb
index 90fcac0e55c..5dd1badbefc 100644
--- a/spec/support/shared_examples/services/metrics/dashboard_shared_examples.rb
+++ b/spec/support/shared_examples/services/metrics/dashboard_shared_examples.rb
@@ -23,7 +23,7 @@ RSpec.shared_examples 'valid dashboard service response for schema' do
end
RSpec.shared_examples 'valid dashboard service response' do
- let(:dashboard_schema) { JSON.parse(fixture_file('lib/gitlab/metrics/dashboard/schemas/dashboard.json')) }
+ let(:dashboard_schema) { Gitlab::Json.parse(fixture_file('lib/gitlab/metrics/dashboard/schemas/dashboard.json')) }
it_behaves_like 'valid dashboard service response for schema'
end
@@ -38,7 +38,7 @@ RSpec.shared_examples 'caches the unprocessed dashboard for subsequent calls' do
end
RSpec.shared_examples 'valid embedded dashboard service response' do
- let(:dashboard_schema) { JSON.parse(fixture_file('lib/gitlab/metrics/dashboard/schemas/embedded_dashboard.json')) }
+ let(:dashboard_schema) { Gitlab::Json.parse(fixture_file('lib/gitlab/metrics/dashboard/schemas/embedded_dashboard.json')) }
it_behaves_like 'valid dashboard service response for schema'
end
diff --git a/spec/support/shared_examples/services/projects/update_repository_storage_service_shared_examples.rb b/spec/support/shared_examples/services/projects/update_repository_storage_service_shared_examples.rb
index d6166ac8188..0e6ecf49cd0 100644
--- a/spec/support/shared_examples/services/projects/update_repository_storage_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/projects/update_repository_storage_service_shared_examples.rb
@@ -47,9 +47,9 @@ RSpec.shared_examples 'moves repository to another storage' do |repository_type|
old_repository_path = repository.full_path
- result = subject.execute('test_second_storage')
+ result = subject.execute
- expect(result[:status]).to eq(:success)
+ expect(result).to be_success
expect(project).not_to be_repository_read_only
expect(project.repository_storage).to eq('test_second_storage')
expect(gitlab_shell.repository_exists?('default', old_project_repository_path)).to be(false)
@@ -62,7 +62,7 @@ RSpec.shared_examples 'moves repository to another storage' do |repository_type|
end
it 'does not enqueue a GC run' do
- expect { subject.execute('test_second_storage') }
+ expect { subject.execute }
.not_to change(GitGarbageCollectWorker.jobs, :count)
end
end
@@ -75,23 +75,25 @@ RSpec.shared_examples 'moves repository to another storage' do |repository_type|
it 'does not enqueue a GC run if housekeeping is disabled' do
stub_application_setting(housekeeping_enabled: false)
- expect { subject.execute('test_second_storage') }
+ expect { subject.execute }
.not_to change(GitGarbageCollectWorker.jobs, :count)
end
it 'enqueues a GC run' do
- expect { subject.execute('test_second_storage') }
+ expect { subject.execute }
.to change(GitGarbageCollectWorker.jobs, :count).by(1)
end
end
end
context 'when the filesystems are the same' do
+ let(:destination) { project.repository_storage }
+
it 'bails out and does nothing' do
- result = subject.execute(project.repository_storage)
+ result = subject.execute
- expect(result[:status]).to eq(:error)
- expect(result[:message]).to match(/SameFilesystemError/)
+ expect(result).to be_error
+ expect(result.message).to match(/SameFilesystemError/)
end
end
@@ -114,9 +116,9 @@ RSpec.shared_examples 'moves repository to another storage' do |repository_type|
expect(GitlabShellWorker).not_to receive(:perform_async)
- result = subject.execute('test_second_storage')
+ result = subject.execute
- expect(result[:status]).to eq(:error)
+ expect(result).to be_error
expect(project).not_to be_repository_read_only
expect(project.repository_storage).to eq('default')
end
@@ -142,9 +144,9 @@ RSpec.shared_examples 'moves repository to another storage' do |repository_type|
expect(GitlabShellWorker).not_to receive(:perform_async)
- result = subject.execute('test_second_storage')
+ result = subject.execute
- expect(result[:status]).to eq(:error)
+ expect(result).to be_error
expect(project).not_to be_repository_read_only
expect(project.repository_storage).to eq('default')
end
diff --git a/spec/support/shared_examples/services/resource_events/change_milestone_service_shared_examples.rb b/spec/support/shared_examples/services/resource_events/change_milestone_service_shared_examples.rb
index 77f64e5e8f8..c5f84e205cf 100644
--- a/spec/support/shared_examples/services/resource_events/change_milestone_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/resource_events/change_milestone_service_shared_examples.rb
@@ -4,7 +4,7 @@ shared_examples 'a milestone events creator' do
let_it_be(:user) { create(:user) }
let(:created_at_time) { Time.utc(2019, 12, 30) }
- let(:service) { described_class.new(resource, user, created_at: created_at_time) }
+ let(:service) { described_class.new(resource, user, created_at: created_at_time, old_milestone: nil) }
context 'when milestone is present' do
let_it_be(:milestone) { create(:milestone) }
@@ -25,10 +25,13 @@ shared_examples 'a milestone events creator' do
resource.milestone = nil
end
+ let(:old_milestone) { create(:milestone, project: resource.project) }
+ let(:service) { described_class.new(resource, user, created_at: created_at_time, old_milestone: old_milestone) }
+
it 'creates the expected event records' do
expect { service.execute }.to change { ResourceMilestoneEvent.count }.by(1)
- expect_event_record(ResourceMilestoneEvent.last, action: 'remove', milestone: nil, state: 'opened')
+ expect_event_record(ResourceMilestoneEvent.last, action: 'remove', milestone: old_milestone, state: 'opened')
end
end
diff --git a/spec/support/shared_examples/services/snippets_shared_examples.rb b/spec/support/shared_examples/services/snippets_shared_examples.rb
new file mode 100644
index 00000000000..51a4a8b1cd9
--- /dev/null
+++ b/spec/support/shared_examples/services/snippets_shared_examples.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'snippets spam check is performed' do
+ shared_examples 'marked as spam' do
+ it 'marks a snippet as spam' do
+ expect(snippet).to be_spam
+ end
+
+ it 'invalidates the snippet' do
+ expect(snippet).to be_invalid
+ end
+
+ it 'creates a new spam_log' do
+ expect { snippet }
+ .to have_spam_log(title: snippet.title, noteable_type: snippet.class.name)
+ end
+
+ it 'assigns a spam_log to an issue' do
+ expect(snippet.spam_log).to eq(SpamLog.last)
+ end
+ end
+
+ let(:extra_opts) do
+ { visibility_level: Gitlab::VisibilityLevel::PUBLIC, request: double(:request, env: {}) }
+ end
+
+ before do
+ expect_next_instance_of(Spam::AkismetService) do |akismet_service|
+ expect(akismet_service).to receive_messages(spam?: true)
+ end
+ end
+
+ [true, false, nil].each do |allow_possible_spam|
+ context "when allow_possible_spam flag is #{allow_possible_spam.inspect}" do
+ before do
+ stub_feature_flags(allow_possible_spam: allow_possible_spam) unless allow_possible_spam.nil?
+ end
+
+ it_behaves_like 'marked as spam'
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/wiki_pages/create_service_shared_examples.rb b/spec/support/shared_examples/services/wiki_pages/create_service_shared_examples.rb
new file mode 100644
index 00000000000..71bdd46572f
--- /dev/null
+++ b/spec/support/shared_examples/services/wiki_pages/create_service_shared_examples.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'WikiPages::CreateService#execute' do |container_type|
+ let(:container) { create(container_type, :wiki_repo) }
+ let(:user) { create(:user) }
+ let(:page_title) { 'Title' }
+
+ let(:opts) do
+ {
+ title: page_title,
+ content: 'Content for wiki page',
+ format: 'markdown'
+ }
+ end
+
+ subject(:service) { described_class.new(container: container, current_user: user, params: opts) }
+
+ it 'creates wiki page with valid attributes' do
+ page = service.execute
+
+ expect(page).to be_valid
+ expect(page).to be_persisted
+ expect(page.title).to eq(opts[:title])
+ expect(page.content).to eq(opts[:content])
+ expect(page.format).to eq(opts[:format].to_sym)
+ end
+
+ it 'executes webhooks' do
+ expect(service).to receive(:execute_hooks).once.with(WikiPage)
+
+ service.execute
+ end
+
+ it 'counts wiki page creation' do
+ counter = Gitlab::UsageDataCounters::WikiPageCounter
+
+ expect { service.execute }.to change { counter.read(:create) }.by 1
+ end
+
+ shared_examples 'correct event created' do
+ it 'creates appropriate events' do
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/216904
+ pending('group wiki support') if container_type == :group
+
+ expect { service.execute }.to change { Event.count }.by 1
+
+ expect(Event.recent.first).to have_attributes(
+ action: Event::CREATED,
+ target: have_attributes(canonical_slug: page_title)
+ )
+ end
+ end
+
+ context 'the new page is at the top level' do
+ let(:page_title) { 'root-level-page' }
+
+ include_examples 'correct event created'
+ end
+
+ context 'the new page is in a subsection' do
+ let(:page_title) { 'subsection/page' }
+
+ include_examples 'correct event created'
+ end
+
+ context 'the feature is disabled' do
+ before do
+ stub_feature_flags(wiki_events: false)
+ end
+
+ it 'does not record the activity' do
+ expect { service.execute }.not_to change(Event, :count)
+ end
+ end
+
+ context 'when the options are bad' do
+ let(:page_title) { '' }
+
+ it 'does not count a creation event' do
+ counter = Gitlab::UsageDataCounters::WikiPageCounter
+
+ expect { service.execute }.not_to change { counter.read(:create) }
+ end
+
+ it 'does not record the activity' do
+ expect { service.execute }.not_to change(Event, :count)
+ end
+
+ it 'reports the error' do
+ expect(service.execute).to be_invalid
+ .and have_attributes(errors: be_present)
+ 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
new file mode 100644
index 00000000000..62541eb3da9
--- /dev/null
+++ b/spec/support/shared_examples/services/wiki_pages/destroy_service_shared_examples.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'WikiPages::DestroyService#execute' do |container_type|
+ let(:container) { create(container_type) }
+
+ let(:user) { create(:user) }
+ let(:page) { create(:wiki_page) }
+
+ subject(:service) { described_class.new(container: container, current_user: user) }
+
+ it 'executes webhooks' do
+ expect(service).to receive(:execute_hooks).once.with(page)
+
+ service.execute(page)
+ end
+
+ it 'increments the delete count' do
+ counter = Gitlab::UsageDataCounters::WikiPageCounter
+
+ expect { service.execute(page) }.to change { counter.read(:delete) }.by 1
+ end
+
+ it 'creates a new wiki page deletion event' do
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/216904
+ pending('group wiki support') if container_type == :group
+
+ expect { service.execute(page) }.to change { Event.count }.by 1
+
+ expect(Event.recent.first).to have_attributes(
+ action: Event::DESTROYED,
+ target: have_attributes(canonical_slug: page.slug)
+ )
+ end
+
+ it 'does not increment the delete count if the deletion failed' do
+ counter = Gitlab::UsageDataCounters::WikiPageCounter
+
+ expect { service.execute(nil) }.not_to change { counter.read(:delete) }
+ end
+
+ context 'the feature is disabled' do
+ before do
+ stub_feature_flags(wiki_events: false)
+ end
+
+ it 'does not record the activity' do
+ expect { service.execute(page) }.not_to change(Event, :count)
+ 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
new file mode 100644
index 00000000000..0dfc99d043b
--- /dev/null
+++ b/spec/support/shared_examples/services/wiki_pages/update_service_shared_examples.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'WikiPages::UpdateService#execute' do |container_type|
+ let(:container) { create(container_type, :wiki_repo) }
+
+ let(:user) { create(:user) }
+ let(:page) { create(:wiki_page) }
+ let(:page_title) { 'New Title' }
+
+ let(:opts) do
+ {
+ content: 'New content for wiki page',
+ format: 'markdown',
+ message: 'New wiki message',
+ title: page_title
+ }
+ end
+
+ subject(:service) { described_class.new(container: container, current_user: user, params: opts) }
+
+ it 'updates the wiki page' do
+ updated_page = service.execute(page)
+
+ expect(updated_page).to be_valid
+ expect(updated_page.message).to eq(opts[:message])
+ expect(updated_page.content).to eq(opts[:content])
+ expect(updated_page.format).to eq(opts[:format].to_sym)
+ expect(updated_page.title).to eq(page_title)
+ end
+
+ it 'executes webhooks' do
+ expect(service).to receive(:execute_hooks).once.with(WikiPage)
+
+ service.execute(page)
+ end
+
+ it 'counts edit events' do
+ counter = Gitlab::UsageDataCounters::WikiPageCounter
+
+ expect { service.execute page }.to change { counter.read(:update) }.by 1
+ end
+
+ shared_examples 'adds activity event' do
+ it 'adds a new wiki page activity event' do
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/216904
+ pending('group wiki support') if container_type == :group
+
+ expect { service.execute(page) }.to change { Event.count }.by 1
+
+ expect(Event.recent.first).to have_attributes(
+ action: Event::UPDATED,
+ wiki_page: page,
+ target_title: page.title
+ )
+ end
+ end
+
+ context 'the page is at the top level' do
+ let(:page_title) { 'Top level page' }
+
+ include_examples 'adds activity event'
+ end
+
+ context 'the page is in a subsection' do
+ let(:page_title) { 'Subsection / secondary page' }
+
+ include_examples 'adds activity event'
+ end
+
+ context 'the feature is disabled' do
+ before do
+ stub_feature_flags(wiki_events: false)
+ end
+
+ it 'does not record the activity' do
+ expect { service.execute(page) }.not_to change(Event, :count)
+ end
+ end
+
+ context 'when the options are bad' do
+ let(:page_title) { '' }
+
+ it 'does not count an edit event' do
+ counter = Gitlab::UsageDataCounters::WikiPageCounter
+
+ expect { service.execute page }.not_to change { counter.read(:update) }
+ end
+
+ it 'does not record the activity' do
+ expect { service.execute page }.not_to change(Event, :count)
+ end
+
+ it 'reports the error' do
+ expect(service.execute(page)).to be_invalid
+ .and have_attributes(errors: be_present)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/wikis/create_attachment_service_shared_examples.rb b/spec/support/shared_examples/services/wikis/create_attachment_service_shared_examples.rb
new file mode 100644
index 00000000000..541e332e3a1
--- /dev/null
+++ b/spec/support/shared_examples/services/wikis/create_attachment_service_shared_examples.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'Wikis::CreateAttachmentService#execute' do |container_type|
+ let(:container) { create(container_type, :wiki_repo) }
+ let(:wiki) { container.wiki }
+
+ let(:user) { create(:user) }
+ let(:file_name) { 'filename.txt' }
+ let(:file_path_regex) { %r{#{described_class::ATTACHMENT_PATH}/\h{32}/#{file_name}} }
+
+ let(:file_opts) do
+ {
+ file_name: file_name,
+ file_content: 'Content of attachment'
+ }
+ end
+ let(:opts) { file_opts }
+
+ let(:service) { Wikis::CreateAttachmentService.new(container: container, current_user: user, params: opts) }
+
+ subject(:service_execute) { service.execute[:result] }
+
+ before do
+ container.add_developer(user)
+ end
+
+ context 'creates branch if it does not exists' do
+ let(:branch_name) { 'new_branch' }
+ let(:opts) { file_opts.merge(branch_name: branch_name) }
+
+ it do
+ expect(wiki.repository.branches).to be_empty
+ expect { service.execute }.to change { wiki.repository.branches.count }.by(1)
+ expect(wiki.repository.branches.first.name).to eq branch_name
+ end
+ end
+
+ it 'adds file to the repository' do
+ expect(wiki.repository.ls_files('HEAD')).to be_empty
+
+ service.execute
+
+ files = wiki.repository.ls_files('HEAD')
+ expect(files.count).to eq 1
+ expect(files.first).to match(file_path_regex)
+ end
+
+ context 'returns' do
+ before do
+ allow(SecureRandom).to receive(:hex).and_return('fixed_hex')
+
+ service_execute
+ end
+
+ it 'returns related information', :aggregate_failures do
+ expect(service_execute[:file_name]).to eq file_name
+ expect(service_execute[:file_path]).to eq 'uploads/fixed_hex/filename.txt'
+ expect(service_execute[:branch]).to eq wiki.default_branch
+ expect(service_execute[:commit]).not_to be_empty
+ end
+ end
+end
diff --git a/spec/support/shared_examples/tasks/gitlab/import_export/measurable_shared_examples.rb b/spec/support/shared_examples/tasks/gitlab/import_export/measurable_shared_examples.rb
deleted file mode 100644
index 5950a1a53e2..00000000000
--- a/spec/support/shared_examples/tasks/gitlab/import_export/measurable_shared_examples.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'measurable' do
- context 'when measurement is enabled' do
- let(:measurement_enabled) { true }
-
- it 'prints measurement results' do
- expect { subject }.to output(including('Measuring enabled...', 'Number of sql calls:', 'GC stats:')).to_stdout
- end
- end
-
- context 'when measurement is not enabled' do
- let(:measurement_enabled) { false }
-
- it 'does not output measurement results' do
- expect { subject }.not_to output(/Measuring enabled.../).to_stdout
- end
- end
-
- context 'when measurement is not provided' do
- let(:measurement_enabled) { nil }
-
- it 'does not output measurement results' do
- expect { subject }.not_to output(/Measuring enabled.../).to_stdout
- end
-
- it 'does not raise any exception' do
- expect { subject }.not_to raise_error
- end
- end
-end
diff --git a/spec/support/shared_examples/workers/authorized_projects_worker_shared_example.rb b/spec/support/shared_examples/workers/authorized_projects_worker_shared_example.rb
new file mode 100644
index 00000000000..fba8b4aadbb
--- /dev/null
+++ b/spec/support/shared_examples/workers/authorized_projects_worker_shared_example.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples "refreshes user's project authorizations" do
+ describe '#perform' do
+ let(:user) { create(:user) }
+
+ subject(:job) { described_class.new }
+
+ it "refreshes user's authorized projects" do
+ expect_any_instance_of(User).to receive(:refresh_authorized_projects)
+
+ job.perform(user.id)
+ end
+
+ context "when the user is not found" do
+ it "does nothing" do
+ expect_any_instance_of(User).not_to receive(:refresh_authorized_projects)
+
+ job.perform(-1)
+ end
+ end
+
+ it_behaves_like "an idempotent worker" do
+ let(:job_args) { user.id }
+
+ it "does not change authorizations when run twice" do
+ group = create(:group)
+ create(:project, namespace: group)
+ group.add_developer(user)
+
+ # Delete the authorization created by the after save hook of the member
+ # created above.
+ user.project_authorizations.delete_all
+
+ expect { job.perform(user.id) }.to change { user.project_authorizations.reload.size }.by(1)
+ expect { job.perform(user.id) }.not_to change { user.project_authorizations.reload.size }
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/workers/gitlab/jira_import/jira_import_workers_shared_examples.rb b/spec/support/shared_examples/workers/gitlab/jira_import/jira_import_workers_shared_examples.rb
index c0d17d6853d..ae8c82cb67c 100644
--- a/spec/support/shared_examples/workers/gitlab/jira_import/jira_import_workers_shared_examples.rb
+++ b/spec/support/shared_examples/workers/gitlab/jira_import/jira_import_workers_shared_examples.rb
@@ -20,7 +20,7 @@ shared_examples 'does not advance to next stage' do
end
end
-shared_examples 'cannot do jira import' do
+shared_examples 'cannot do Jira import' do
it 'does not advance to next stage' do
worker = described_class.new
expect(worker).not_to receive(:import)
diff --git a/spec/support/shared_examples/workers/pages_domain_cron_worker_shared_examples.rb b/spec/support/shared_examples/workers/pages_domain_cron_worker_shared_examples.rb
index 9e8102aea53..c79e3ed7d21 100644
--- a/spec/support/shared_examples/workers/pages_domain_cron_worker_shared_examples.rb
+++ b/spec/support/shared_examples/workers/pages_domain_cron_worker_shared_examples.rb
@@ -3,12 +3,14 @@
RSpec.shared_examples 'a pages cronjob scheduling jobs with context' do |scheduled_worker_class|
let(:worker) { described_class.new }
- it 'does not cause extra queries for multiple domains' do
- control = ActiveRecord::QueryRecorder.new { worker.perform }
+ context 'with RequestStore enabled', :request_store do
+ it 'does not cause extra queries for multiple domains' do
+ control = ActiveRecord::QueryRecorder.new { worker.perform }
- extra_domain
+ extra_domain
- expect { worker.perform }.not_to exceed_query_limit(control)
+ expect { worker.perform }.not_to exceed_query_limit(control)
+ end
end
it 'schedules the renewal with a context' do
diff --git a/spec/support/shared_examples/workers/reactive_cacheable_shared_examples.rb b/spec/support/shared_examples/workers/reactive_cacheable_shared_examples.rb
new file mode 100644
index 00000000000..0bbd0e2a90d
--- /dev/null
+++ b/spec/support/shared_examples/workers/reactive_cacheable_shared_examples.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'reactive cacheable worker' do
+ describe '#perform' do
+ context 'when reactive cache worker class is found' do
+ let!(:cluster) { create(:cluster, :project, :provided_by_gcp) }
+ let(:project) { cluster.project }
+ let!(:environment) { create(:environment, project: project) }
+
+ it 'calls #exclusively_update_reactive_cache!' do
+ expect_any_instance_of(Environment).to receive(:exclusively_update_reactive_cache!)
+
+ described_class.new.perform("Environment", environment.id)
+ end
+
+ context 'when ReactiveCaching::ExceededReactiveCacheLimit is raised' do
+ it 'avoids failing the job and tracks via Gitlab::ErrorTracking' do
+ allow_any_instance_of(Environment).to receive(:exclusively_update_reactive_cache!)
+ .and_raise(ReactiveCaching::ExceededReactiveCacheLimit)
+
+ expect(Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(kind_of(ReactiveCaching::ExceededReactiveCacheLimit))
+
+ described_class.new.perform("Environment", environment.id)
+ end
+ end
+ end
+
+ context 'when reactive cache worker class is not found' do
+ it 'raises no error' do
+ expect { described_class.new.perform("Environment", -1) }.not_to raise_error
+ end
+ end
+
+ context 'when reactive cache worker class is invalid' do
+ it 'raises no error' do
+ expect { described_class.new.perform("FooBarKux", -1) }.not_to raise_error
+ end
+ end
+ end
+
+ describe 'worker context' do
+ it 'sets the related class on the job' do
+ described_class.perform_async('Environment', 1, 'other', 'argument')
+
+ scheduled_job = described_class.jobs.first
+
+ expect(scheduled_job).to include('meta.related_class' => 'Environment')
+ end
+
+ it 'sets the related class on the job when it was passed as a class' do
+ described_class.perform_async(Project, 1, 'other', 'argument')
+
+ scheduled_job = described_class.jobs.first
+
+ expect(scheduled_job).to include('meta.related_class' => 'Project')
+ end
+ end
+end
diff --git a/spec/support/sidekiq.rb b/spec/support/sidekiq.rb
index 9fa8df39019..374997af1ec 100644
--- a/spec/support/sidekiq.rb
+++ b/spec/support/sidekiq.rb
@@ -1,14 +1,23 @@
# frozen_string_literal: true
RSpec.configure do |config|
+ def gitlab_sidekiq_inline(&block)
+ # We need to cleanup the queues before running jobs in specs because the
+ # middleware might have written to redis
+ redis_queues_cleanup!
+ Sidekiq::Testing.inline!(&block)
+ ensure
+ redis_queues_cleanup!
+ end
+
# As we'll review the examples with this tag, we should either:
# - fix the example to not require Sidekiq inline mode (and remove this tag)
# - explicitly keep the inline mode and change the tag for `:sidekiq_inline` instead
config.around(:example, :sidekiq_might_not_need_inline) do |example|
- Sidekiq::Testing.inline! { example.run }
+ gitlab_sidekiq_inline { example.run }
end
config.around(:example, :sidekiq_inline) do |example|
- Sidekiq::Testing.inline! { example.run }
+ gitlab_sidekiq_inline { example.run }
end
end
diff --git a/spec/support/sidekiq_middleware.rb b/spec/support/sidekiq_middleware.rb
index 1380f4394d8..62f81ef1669 100644
--- a/spec/support/sidekiq_middleware.rb
+++ b/spec/support/sidekiq_middleware.rb
@@ -31,3 +31,16 @@ class DisableQueryLimit
end
end
end
+
+# When running `Sidekiq::Testing.inline!` each job is using a request-store.
+# This middleware makes sure the values don't leak into eachother.
+class IsolatedRequestStore
+ def call(_worker, msg, queue)
+ old_store = RequestStore.store.dup
+ RequestStore.clear!
+
+ yield
+
+ RequestStore.store = old_store
+ end
+end
diff --git a/spec/support/unicorn.rb b/spec/support/unicorn.rb
new file mode 100644
index 00000000000..0b01fc9e26c
--- /dev/null
+++ b/spec/support/unicorn.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+REQUEST_CLASSES = [
+ ::Grape::Request,
+ ::Rack::Request
+].freeze
+
+def request_body_class
+ return ::Unicorn::TeeInput if defined?(::Unicorn)
+
+ Class.new(StringIO) do
+ def string
+ raise NotImplementedError, '#string is only valid under Puma which uses StringIO, use #read instead'
+ end
+ end
+end
+
+RSpec.configure do |config|
+ config.before(:each, :unicorn) do
+ REQUEST_CLASSES.each do |request_class|
+ allow_any_instance_of(request_class)
+ .to receive(:body).and_wrap_original do |m, *args|
+ request_body_class.new(m.call(*args).read)
+ end
+ end
+ end
+end
diff --git a/spec/support/webmock.rb b/spec/support/webmock.rb
index 57acc3b63b1..f952f7f0985 100644
--- a/spec/support/webmock.rb
+++ b/spec/support/webmock.rb
@@ -15,4 +15,20 @@ def webmock_allowed_hosts
end.compact.uniq
end
-WebMock.disable_net_connect!(allow_localhost: true, allow: webmock_allowed_hosts)
+# This prevents Selenium/WebMock from spawning thousands of connections
+# while waiting for an element to appear via Capybara's find:
+# https://github.com/teamcapybara/capybara/issues/2322#issuecomment-619321520
+def webmock_enable_with_http_connect_on_start!
+ webmock_enable!(net_http_connect_on_start: true)
+end
+
+def webmock_enable!(options = {})
+ WebMock.disable_net_connect!(
+ {
+ allow_localhost: true,
+ allow: webmock_allowed_hosts
+ }.merge(options)
+ )
+end
+
+webmock_enable!
diff --git a/spec/support_specs/helpers/active_record/query_recorder_spec.rb b/spec/support_specs/helpers/active_record/query_recorder_spec.rb
index 0827ce37b07..d15fbb5d4c3 100644
--- a/spec/support_specs/helpers/active_record/query_recorder_spec.rb
+++ b/spec/support_specs/helpers/active_record/query_recorder_spec.rb
@@ -3,8 +3,12 @@
require 'spec_helper'
describe ActiveRecord::QueryRecorder do
- class TestQueries < ActiveRecord::Base
- self.table_name = 'schema_migrations'
+ before do
+ stub_const('TestQueries', Class.new(ActiveRecord::Base))
+
+ TestQueries.class_eval do
+ self.table_name = 'schema_migrations'
+ end
end
describe 'detecting the right number of calls and their origin' do
diff --git a/spec/support_specs/helpers/stub_feature_flags_spec.rb b/spec/support_specs/helpers/stub_feature_flags_spec.rb
new file mode 100644
index 00000000000..b6e230075f2
--- /dev/null
+++ b/spec/support_specs/helpers/stub_feature_flags_spec.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe StubFeatureFlags do
+ before do
+ # reset stub introduced by `stub_feature_flags`
+ allow(Feature).to receive(:enabled?).and_call_original
+ end
+
+ context 'if not stubbed' do
+ it 'features are disabled by default' do
+ expect(Feature.enabled?(:test_feature)).to eq(false)
+ end
+ end
+
+ describe '#stub_feature_flags' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:feature_name) { :test_feature }
+
+ context 'when checking global state' do
+ where(:feature_actors, :expected_result) do
+ false | false
+ true | true
+ :A | false
+ %i[A] | false
+ %i[A B] | false
+ end
+
+ with_them do
+ before do
+ stub_feature_flags(feature_name => feature_actors)
+ end
+
+ it { expect(Feature.enabled?(feature_name)).to eq(expected_result) }
+ it { expect(Feature.disabled?(feature_name)).not_to eq(expected_result) }
+
+ context 'default_enabled does not impact feature state' do
+ it { expect(Feature.enabled?(feature_name, default_enabled: true)).to eq(expected_result) }
+ it { expect(Feature.disabled?(feature_name, default_enabled: true)).not_to eq(expected_result) }
+ end
+ end
+ end
+
+ context 'when checking scoped state' do
+ where(:feature_actors, :tested_actor, :expected_result) do
+ false | nil | false
+ true | nil | true
+ false | :A | false
+ true | :A | true
+ :A | nil | false
+ :A | :A | true
+ :A | :B | false
+ %i[A] | nil | false
+ %i[A] | :A | true
+ %i[A] | :B | false
+ %i[A B] | nil | false
+ %i[A B] | :A | true
+ %i[A B] | :B | true
+ end
+
+ with_them do
+ before do
+ stub_feature_flags(feature_name => feature_actors)
+ end
+
+ it { expect(Feature.enabled?(feature_name, tested_actor)).to eq(expected_result) }
+ it { expect(Feature.disabled?(feature_name, tested_actor)).not_to eq(expected_result) }
+
+ context 'default_enabled does not impact feature state' do
+ it { expect(Feature.enabled?(feature_name, tested_actor, default_enabled: true)).to eq(expected_result) }
+ it { expect(Feature.disabled?(feature_name, tested_actor, default_enabled: true)).not_to eq(expected_result) }
+ end
+ end
+ end
+
+ context 'type handling' do
+ context 'raises error' do
+ where(:feature_actors) do
+ ['string', 1, 1.0, OpenStruct.new]
+ end
+
+ with_them do
+ subject { stub_feature_flags(feature_name => feature_actors) }
+
+ it { expect { subject }.to raise_error(ArgumentError, /accepts only/) }
+ end
+ end
+
+ context 'does not raise error' do
+ where(:feature_actors) do
+ [true, false, nil, :symbol, double, User.new]
+ end
+
+ with_them do
+ subject { stub_feature_flags(feature_name => feature_actors) }
+
+ it { expect { subject }.not_to raise_error }
+ end
+ end
+ end
+
+ it 'subsquent run changes state' do
+ # enable FF only on A
+ stub_feature_flags(test_feature: %i[A])
+ expect(Feature.enabled?(:test_feature)).to eq(false)
+ expect(Feature.enabled?(:test_feature, :A)).to eq(true)
+ expect(Feature.enabled?(:test_feature, :B)).to eq(false)
+
+ # enable FF only on B
+ stub_feature_flags(test_feature: %i[B])
+ expect(Feature.enabled?(:test_feature)).to eq(false)
+ expect(Feature.enabled?(:test_feature, :A)).to eq(false)
+ expect(Feature.enabled?(:test_feature, :B)).to eq(true)
+
+ # enable FF on all
+ stub_feature_flags(test_feature: true)
+ expect(Feature.enabled?(:test_feature)).to eq(true)
+ expect(Feature.enabled?(:test_feature, :A)).to eq(true)
+ expect(Feature.enabled?(:test_feature, :B)).to eq(true)
+
+ # disable FF on all
+ stub_feature_flags(test_feature: false)
+ expect(Feature.enabled?(:test_feature)).to eq(false)
+ expect(Feature.enabled?(:test_feature, :A)).to eq(false)
+ expect(Feature.enabled?(:test_feature, :B)).to eq(false)
+ end
+ end
+end
diff --git a/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb b/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb
index 55bfb7acd9d..9ee00b4297b 100644
--- a/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb
+++ b/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb
@@ -22,18 +22,6 @@ describe 'gitlab:artifacts namespace rake task' do
context 'when local storage is used' do
let(:store) { ObjectStorage::Store::LOCAL }
- context 'and job does not have file store defined' do
- let(:object_storage_enabled) { true }
- let(:store) { nil }
-
- it "migrates file to remote storage" do
- subject
-
- expect(artifact.reload.file_store).to eq(ObjectStorage::Store::REMOTE)
- expect(job_trace.reload.file_store).to eq(ObjectStorage::Store::REMOTE)
- end
- end
-
context 'and remote storage is defined' do
let(:object_storage_enabled) { true }
diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb
index 0cc92680582..d9fdc183bfe 100644
--- a/spec/tasks/gitlab/gitaly_rake_spec.rb
+++ b/spec/tasks/gitlab/gitaly_rake_spec.rb
@@ -46,15 +46,13 @@ describe 'gitlab:gitaly namespace rake task' do
it 'calls checkout_or_clone_version with the right arguments' do
expect(main_object)
- .to receive(:checkout_or_clone_version).with(version: version, repo: repo, target_dir: clone_path)
+ .to receive(:checkout_or_clone_version).with(version: version, repo: repo, target_dir: clone_path, clone_opts: %w[--depth 1])
subject
end
end
describe 'gmake/make' do
- let(:command_preamble) { %w[/usr/bin/env -u RUBYOPT -u BUNDLE_GEMFILE] }
-
before do
stub_env('CI', false)
FileUtils.mkdir_p(clone_path)
@@ -69,7 +67,7 @@ describe 'gitlab:gitaly namespace rake task' do
it 'calls gmake in the gitaly directory' do
expect(Gitlab::Popen).to receive(:popen).with(%w[which gmake]).and_return(['/usr/bin/gmake', 0])
- expect(main_object).to receive(:run_command!).with(command_preamble + %w[gmake]).and_return(true)
+ expect(Gitlab::Popen).to receive(:popen).with(%w[gmake], nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil }).and_return(true)
subject
end
@@ -82,7 +80,7 @@ describe 'gitlab:gitaly namespace rake task' do
end
it 'calls make in the gitaly directory' do
- expect(main_object).to receive(:run_command!).with(command_preamble + %w[make]).and_return(true)
+ expect(Gitlab::Popen).to receive(:popen).with(%w[make], nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil }).and_return(true)
subject
end
@@ -99,7 +97,7 @@ describe 'gitlab:gitaly namespace rake task' do
end
it 'calls make in the gitaly directory with --no-deployment flag for bundle' do
- expect(main_object).to receive(:run_command!).with(command_preamble + command).and_return(true)
+ expect(Gitlab::Popen).to receive(:popen).with(command, nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil }).and_return(true)
subject
end
diff --git a/spec/tasks/gitlab/snippets_rake_spec.rb b/spec/tasks/gitlab/snippets_rake_spec.rb
new file mode 100644
index 00000000000..c4bb8d7897c
--- /dev/null
+++ b/spec/tasks/gitlab/snippets_rake_spec.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+require 'rake_helper'
+
+describe 'gitlab:snippets namespace rake task' do
+ let_it_be(:user) { create(:user)}
+ let_it_be(:migrated) { create(:personal_snippet, :repository, author: user) }
+ let(:non_migrated) { create_list(:personal_snippet, 3, author: user) }
+ let(:non_migrated_ids) { non_migrated.pluck(:id) }
+
+ before :all do
+ Rake.application.rake_require 'tasks/gitlab/snippets'
+ end
+
+ describe 'migrate' do
+ subject { run_rake_task('gitlab:snippets:migrate') }
+
+ before do
+ stub_env('SNIPPET_IDS' => non_migrated_ids.join(','))
+ end
+
+ it 'can migrate specific snippets passing ids' do
+ expect_next_instance_of(Gitlab::BackgroundMigration::BackfillSnippetRepositories) do |instance|
+ expect(instance).to receive(:perform_by_ids).with(non_migrated_ids).and_call_original
+ end
+
+ expect { subject }.to output(/All snippets were migrated successfully/).to_stdout
+ end
+
+ it 'returns the ids of those snippet that failed the migration' do
+ expect_next_instance_of(Gitlab::BackgroundMigration::BackfillSnippetRepositories) do |instance|
+ expect(instance).to receive(:perform_by_ids).with(non_migrated_ids)
+ end
+
+ expect { subject }.to output(/The following snippets couldn't be migrated:\n#{non_migrated_ids.join(',')}/).to_stdout
+ end
+
+ it 'fails if the SNIPPET_IDS env var is not set' do
+ stub_env('SNIPPET_IDS' => nil)
+
+ expect { subject }.to raise_error(RuntimeError, 'Please supply the list of ids through the SNIPPET_IDS env var')
+ end
+
+ it 'fails if the number of ids provided is higher than the limit' do
+ stub_env('LIMIT' => 2)
+
+ expect { subject }.to raise_error(RuntimeError, /The number of ids provided is higher than/)
+ end
+
+ it 'fails if the env var LIMIT is invalid' do
+ stub_env('LIMIT' => 'foo')
+
+ expect { subject }.to raise_error(RuntimeError, 'Invalid limit value')
+ end
+
+ it 'fails if the ids are invalid' do
+ stub_env('SNIPPET_IDS' => '1,2,a')
+
+ expect { subject }.to raise_error(RuntimeError, 'Invalid id provided')
+ end
+
+ it 'fails if the snippet background migration is running' do
+ Sidekiq::Testing.disable! do
+ BackgroundMigrationWorker.perform_in(180, 'BackfillSnippetRepositories', [non_migrated.first.id, non_migrated.last.id])
+ expect(Sidekiq::ScheduledSet.new).to be_one
+
+ expect { subject }.to raise_error(RuntimeError, /There are already snippet migrations running/)
+
+ Sidekiq::ScheduledSet.new.clear
+ end
+ end
+ end
+
+ describe 'migration_status' do
+ subject { run_rake_task('gitlab:snippets:migration_status') }
+
+ it 'returns a message when the background migration is not running' do
+ expect { subject }.to output("There are no snippet migrations running\n").to_stdout
+ end
+
+ it 'returns a message saying that the background migration is running' do
+ Sidekiq::Testing.disable! do
+ BackgroundMigrationWorker.perform_in(180, 'BackfillSnippetRepositories', [non_migrated.first.id, non_migrated.last.id])
+ expect(Sidekiq::ScheduledSet.new).to be_one
+
+ expect { subject }.to output("There are snippet migrations running\n").to_stdout
+
+ Sidekiq::ScheduledSet.new.clear
+ end
+ end
+ end
+
+ describe 'list_non_migrated' do
+ subject { run_rake_task('gitlab:snippets:list_non_migrated') }
+
+ it 'returns a message if all snippets are migrated' do
+ expect { subject }.to output("All snippets have been successfully migrated\n").to_stdout
+ end
+
+ context 'when there are still non migrated snippets' do
+ let!(:non_migrated) { create_list(:personal_snippet, 3, author: user) }
+
+ it 'returns a message returning the non migrated snippets ids' do
+ expect { subject }.to output(/#{non_migrated_ids}/).to_stdout
+ end
+
+ it 'returns as many snippet ids as the limit set' do
+ stub_env('LIMIT' => 1)
+
+ expect { subject }.to output(/#{non_migrated_ids[0]}/).to_stdout
+ end
+ end
+ end
+end
diff --git a/spec/tasks/gitlab/task_helpers_spec.rb b/spec/tasks/gitlab/task_helpers_spec.rb
index 4546d3bdfaf..8e6872f4d6f 100644
--- a/spec/tasks/gitlab/task_helpers_spec.rb
+++ b/spec/tasks/gitlab/task_helpers_spec.rb
@@ -28,7 +28,7 @@ describe Gitlab::TaskHelpers do
context "target_dir doesn't exist" do
it 'clones the repo' do
- expect(subject).to receive(:clone_repo).with(repo, clone_path)
+ expect(subject).to receive(:clone_repo).with(repo, clone_path, clone_opts: [])
subject.checkout_or_clone_version(version: version, repo: repo, target_dir: clone_path)
end
@@ -45,6 +45,12 @@ describe Gitlab::TaskHelpers do
subject.checkout_or_clone_version(version: version, repo: repo, target_dir: clone_path)
end
end
+
+ it 'accepts clone_opts' do
+ expect(subject).to receive(:clone_repo).with(repo, clone_path, clone_opts: %w[--depth 1])
+
+ subject.checkout_or_clone_version(version: version, repo: repo, target_dir: clone_path, clone_opts: %w[--depth 1])
+ end
end
describe '#clone_repo' do
@@ -54,6 +60,13 @@ describe Gitlab::TaskHelpers do
subject.clone_repo(repo, clone_path)
end
+
+ it 'accepts clone_opts' do
+ expect(subject)
+ .to receive(:run_command!).with(%W[#{Gitlab.config.git.bin_path} clone --depth 1 -- #{repo} #{clone_path}])
+
+ subject.clone_repo(repo, clone_path, clone_opts: %w[--depth 1])
+ end
end
describe '#checkout_version' do
diff --git a/spec/tasks/gitlab/uploads/migrate_rake_spec.rb b/spec/tasks/gitlab/uploads/migrate_rake_spec.rb
index 8ea0a98a1dc..49026cd74f9 100644
--- a/spec/tasks/gitlab/uploads/migrate_rake_spec.rb
+++ b/spec/tasks/gitlab/uploads/migrate_rake_spec.rb
@@ -119,4 +119,16 @@ describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
it_behaves_like 'enqueue upload migration jobs in batch', batch: 4
end
+
+ context 'for DesignManagement::DesignV432x230Uploader' do
+ let(:uploader_class) { DesignManagement::DesignV432x230Uploader }
+ let(:model_class) { DesignManagement::Action }
+ let(:mounted_as) { :image_v432x230 }
+
+ before do
+ create_list(:design_action, 10, :with_image_v432x230)
+ end
+
+ it_behaves_like 'enqueue upload migration jobs in batch', batch: 4
+ end
end
diff --git a/spec/tasks/gitlab/workhorse_rake_spec.rb b/spec/tasks/gitlab/workhorse_rake_spec.rb
index b7877a84185..139652ac258 100644
--- a/spec/tasks/gitlab/workhorse_rake_spec.rb
+++ b/spec/tasks/gitlab/workhorse_rake_spec.rb
@@ -36,7 +36,7 @@ describe 'gitlab:workhorse namespace rake task' do
it 'calls checkout_or_clone_version with the right arguments' do
expect(main_object)
- .to receive(:checkout_or_clone_version).with(version: version, repo: repo, target_dir: clone_path)
+ .to receive(:checkout_or_clone_version).with(version: version, repo: repo, target_dir: clone_path, clone_opts: %w[--depth 1])
run_rake_task('gitlab:workhorse:install', clone_path)
end
diff --git a/spec/uploaders/content_type_whitelist_spec.rb b/spec/uploaders/content_type_whitelist_spec.rb
index 4689f83759d..32d030cdfee 100644
--- a/spec/uploaders/content_type_whitelist_spec.rb
+++ b/spec/uploaders/content_type_whitelist_spec.rb
@@ -3,16 +3,20 @@
require 'spec_helper'
describe ContentTypeWhitelist do
- class DummyUploader < CarrierWave::Uploader::Base
- include ContentTypeWhitelist::Concern
+ let_it_be(:model) { build_stubbed(:user) }
+ let!(:uploader) do
+ stub_const('DummyUploader', Class.new(CarrierWave::Uploader::Base))
+
+ DummyUploader.class_eval do
+ include ContentTypeWhitelist::Concern
- def content_type_whitelist
- %w[image/png image/jpeg]
+ def content_type_whitelist
+ %w[image/png image/jpeg]
+ end
end
- end
- let_it_be(:model) { build_stubbed(:user) }
- let_it_be(:uploader) { DummyUploader.new(model, :dummy) }
+ DummyUploader.new(model, :dummy)
+ end
context 'upload whitelisted file content type' do
let(:path) { File.join('spec', 'fixtures', 'rails_sample.jpg') }
diff --git a/spec/uploaders/design_management/design_v432x230_uploader_spec.rb b/spec/uploaders/design_management/design_v432x230_uploader_spec.rb
new file mode 100644
index 00000000000..8c62b6ad6a8
--- /dev/null
+++ b/spec/uploaders/design_management/design_v432x230_uploader_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::DesignV432x230Uploader do
+ include CarrierWave::Test::Matchers
+
+ let(:model) { create(:design_action, :with_image_v432x230) }
+ let(:upload) { create(:upload, :design_action_image_v432x230_upload, model: model) }
+
+ subject(:uploader) { described_class.new(model, :image_v432x230) }
+
+ it_behaves_like 'builds correct paths',
+ store_dir: %r[uploads/-/system/design_management/action/image_v432x230/],
+ upload_path: %r[uploads/-/system/design_management/action/image_v432x230/],
+ relative_path: %r[uploads/-/system/design_management/action/image_v432x230/],
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/design_management/action/image_v432x230/]
+
+ context 'object_store is REMOTE' do
+ before do
+ stub_uploads_object_storage
+ end
+
+ include_context 'with storage', described_class::Store::REMOTE
+
+ it_behaves_like 'builds correct paths',
+ store_dir: %r[design_management/action/image_v432x230/],
+ upload_path: %r[design_management/action/image_v432x230/],
+ relative_path: %r[design_management/action/image_v432x230/]
+ end
+
+ describe "#migrate!" do
+ before do
+ uploader.store!(fixture_file_upload('spec/fixtures/dk.png'))
+ stub_uploads_object_storage
+ end
+
+ it_behaves_like 'migrates', to_store: described_class::Store::REMOTE
+ it_behaves_like 'migrates', from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL
+ end
+
+ it 'resizes images', :aggregate_failures do
+ image_loader = CarrierWave::Test::Matchers::ImageLoader
+ original_file = fixture_file_upload('spec/fixtures/dk.png')
+ uploader.store!(original_file)
+
+ expect(
+ image_loader.load_image(original_file.tempfile.path)
+ ).to have_attributes(
+ width: 460,
+ height: 322
+ )
+ expect(
+ image_loader.load_image(uploader.file.file)
+ ).to have_attributes(
+ width: 329,
+ height: 230
+ )
+ end
+
+ context 'accept whitelist file content type' do
+ # We need to feed through a valid path, but we force the parsed mime type
+ # in a stub below so we can set any path.
+ let_it_be(:path) { File.join('spec', 'fixtures', 'dk.png') }
+
+ where(:mime_type) { described_class::MIME_TYPE_WHITELIST }
+
+ with_them do
+ include_context 'force content type detection to mime_type'
+
+ it_behaves_like 'accepted carrierwave upload'
+ end
+ end
+
+ context 'upload non-whitelisted file content type' do
+ let_it_be(:path) { File.join('spec', 'fixtures', 'logo_sample.svg') }
+
+ it_behaves_like 'denied carrierwave upload'
+ end
+
+ context 'upload misnamed non-whitelisted file content type' do
+ let_it_be(:path) { File.join('spec', 'fixtures', 'not_a_png.png') }
+
+ it_behaves_like 'denied carrierwave upload'
+ end
+end
diff --git a/spec/uploaders/records_uploads_spec.rb b/spec/uploaders/records_uploads_spec.rb
index 71eff23c77c..1a3c416c74a 100644
--- a/spec/uploaders/records_uploads_spec.rb
+++ b/spec/uploaders/records_uploads_spec.rb
@@ -4,7 +4,9 @@ require 'spec_helper'
describe RecordsUploads do
let!(:uploader) do
- class RecordsUploadsExampleUploader < GitlabUploader
+ stub_const('RecordsUploadsExampleUploader', Class.new(GitlabUploader))
+
+ RecordsUploadsExampleUploader.class_eval do
include RecordsUploads::Concern
storage :file
diff --git a/spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb b/spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb
index fcb8f4e51b5..7bf8512a6fd 100644
--- a/spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb
+++ b/spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb
@@ -65,4 +65,16 @@ describe ObjectStorage::MigrateUploadsWorker do
end
end
end
+
+ context 'for DesignManagement::DesignV432x230Uploader' do
+ let(:model_class) { DesignManagement::Action }
+ let!(:design_actions) { create_list(:design_action, 10, :with_image_v432x230) }
+ let(:mounted_as) { :image_v432x230 }
+
+ before do
+ stub_uploads_object_storage(DesignManagement::DesignV432x230Uploader)
+ end
+
+ it_behaves_like 'uploads migration worker'
+ end
end
diff --git a/spec/validators/cron_freeze_period_timezone_validator_spec.rb b/spec/validators/cron_freeze_period_timezone_validator_spec.rb
new file mode 100644
index 00000000000..d283b89fa54
--- /dev/null
+++ b/spec/validators/cron_freeze_period_timezone_validator_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe CronFreezePeriodTimezoneValidator do
+ using RSpec::Parameterized::TableSyntax
+
+ subject { create :ci_freeze_period }
+
+ where(:freeze_start, :freeze_end, :is_valid) do
+ '0 23 * * 5' | '0 7 * * 1' | true
+ '0 23 * * 5' | 'invalid' | false
+ 'invalid' | '0 7 * * 1' | false
+ end
+
+ with_them do
+ it 'crontab validation' do
+ subject.freeze_start = freeze_start
+ subject.freeze_end = freeze_end
+
+ expect(subject.valid?).to eq(is_valid)
+ end
+ end
+end
diff --git a/spec/validators/cron_validator_spec.rb b/spec/validators/cron_validator_spec.rb
new file mode 100644
index 00000000000..d6605610402
--- /dev/null
+++ b/spec/validators/cron_validator_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe CronValidator do
+ subject do
+ Class.new do
+ include ActiveModel::Model
+ include ActiveModel::Validations
+ attr_accessor :cron
+ validates :cron, cron: true
+
+ def cron_timezone
+ 'UTC'
+ end
+ end.new
+ end
+
+ it 'validates valid crontab' do
+ subject.cron = '0 23 * * 5'
+
+ expect(subject.valid?).to be_truthy
+ end
+
+ it 'validates invalid crontab' do
+ subject.cron = 'not a cron'
+
+ expect(subject.valid?).to be_falsy
+ end
+
+ context 'cron field is not whitelisted' do
+ subject do
+ Class.new do
+ include ActiveModel::Model
+ include ActiveModel::Validations
+ attr_accessor :cron_partytime
+ validates :cron_partytime, cron: true
+ end.new
+ end
+
+ it 'raises an error' do
+ subject.cron_partytime = '0 23 * * 5'
+
+ expect { subject.valid? }.to raise_error(StandardError, "Non-whitelisted attribute")
+ end
+ end
+end
diff --git a/spec/views/admin/sessions/new.html.haml_spec.rb b/spec/views/admin/sessions/new.html.haml_spec.rb
index 05601e5471e..b52ad0f9505 100644
--- a/spec/views/admin/sessions/new.html.haml_spec.rb
+++ b/spec/views/admin/sessions/new.html.haml_spec.rb
@@ -6,20 +6,26 @@ describe 'admin/sessions/new.html.haml' do
let(:user) { create(:admin) }
before do
+ disable_all_signin_methods
+
allow(view).to receive(:current_user).and_return(user)
- allow(view).to receive(:omniauth_enabled?).and_return(false)
end
context 'internal admin user' do
+ before do
+ allow(view).to receive(:allow_admin_mode_password_authentication_for_web?).and_return(true)
+ end
+
it 'shows enter password form' do
render
+ expect(rendered).to have_selector('[data-qa-selector="sign_in_tab"]')
expect(rendered).to have_css('#login-pane.active')
- expect(rendered).to have_selector('input[name="user[password]"]')
+ expect(rendered).to have_selector('[data-qa-selector="password_field"]')
end
it 'warns authentication not possible if password not set' do
- allow(user).to receive(:require_password_creation_for_web?).and_return(true)
+ allow(view).to receive(:allow_admin_mode_password_authentication_for_web?).and_return(false)
render
@@ -39,8 +45,53 @@ describe 'admin/sessions/new.html.haml' do
expect(rendered).to have_css('.omniauth-container')
expect(rendered).to have_content _('Sign in with')
-
expect(rendered).not_to have_content _('No authentication methods configured.')
end
end
+
+ context 'ldap authentication' do
+ let(:user) { create(:omniauth_user, :admin, extern_uid: 'my-uid', provider: 'ldapmain') }
+ let(:server) { { provider_name: 'ldapmain', label: 'LDAP' }.with_indifferent_access }
+
+ before do
+ enable_ldap
+ end
+
+ it 'is shown when enabled' do
+ render
+
+ expect(rendered).to have_selector('[data-qa-selector="ldap_tab"]')
+ expect(rendered).to have_css('.login-box#ldapmain')
+ expect(rendered).to have_field('LDAP Username')
+ expect(rendered).not_to have_content('No authentication methods configured')
+ end
+
+ it 'is not shown when LDAP sign in is disabled' do
+ disable_ldap_sign_in
+
+ render
+
+ expect(rendered).not_to have_selector('[data-qa-selector="ldap_tab"]')
+ expect(rendered).not_to have_field('LDAP Username')
+ expect(rendered).to have_content('No authentication methods configured')
+ end
+
+ def enable_ldap
+ allow(view).to receive(:ldap_servers).and_return([server])
+ allow(view).to receive(:form_based_providers).and_return([:ldapmain])
+ allow(view).to receive(:omniauth_callback_path).with(:user, 'ldapmain').and_return('/ldapmain')
+ allow(view).to receive(:ldap_sign_in_enabled?).and_return(true)
+ end
+
+ def disable_ldap_sign_in
+ allow(view).to receive(:ldap_sign_in_enabled?).and_return(false)
+ allow(view).to receive(:ldap_servers).and_return([])
+ end
+ end
+
+ def disable_all_signin_methods
+ allow(view).to receive(:password_authentication_enabled_for_web?).and_return(false)
+ allow(view).to receive(:omniauth_enabled?).and_return(false)
+ allow(view).to receive(:ldap_sign_in_enabled?).and_return(false)
+ end
end
diff --git a/spec/views/admin/users/_user.html.haml_spec.rb b/spec/views/admin/users/_user.html.haml_spec.rb
index 96d84229d94..de5a291a6f8 100644
--- a/spec/views/admin/users/_user.html.haml_spec.rb
+++ b/spec/views/admin/users/_user.html.haml_spec.rb
@@ -9,7 +9,7 @@ describe 'admin/users/_user.html.haml' do
context 'internal users' do
context 'when showing a `Ghost User`' do
- let(:user) { create(:user, ghost: true) }
+ let(:user) { create(:user, :ghost) }
it 'does not render action buttons' do
render
@@ -27,6 +27,16 @@ describe 'admin/users/_user.html.haml' do
expect(rendered).not_to have_selector('.table-action-buttons')
end
end
+
+ context 'when showing a `Migration User`' do
+ let(:user) { create(:user, user_type: :migration_bot) }
+
+ it 'does not render action buttons' do
+ render
+
+ expect(rendered).not_to have_selector('.table-action-buttons')
+ end
+ end
end
context 'when showing an external user' do
diff --git a/spec/views/devise/sessions/new.html.haml_spec.rb b/spec/views/devise/sessions/new.html.haml_spec.rb
index 66afc2af7ce..27bd683bbf0 100644
--- a/spec/views/devise/sessions/new.html.haml_spec.rb
+++ b/spec/views/devise/sessions/new.html.haml_spec.rb
@@ -54,14 +54,14 @@ describe 'devise/sessions/new' do
def enable_ldap
stub_ldap_setting(enabled: true)
- assign(:ldap_servers, [server])
+ allow(view).to receive(:ldap_servers).and_return([server])
allow(view).to receive(:form_based_providers).and_return([:ldapmain])
allow(view).to receive(:omniauth_callback_path).with(:user, 'ldapmain').and_return('/ldapmain')
end
def disable_ldap_sign_in
allow(view).to receive(:ldap_sign_in_enabled?).and_return(false)
- assign(:ldap_servers, [])
+ allow(view).to receive(:ldap_servers).and_return([])
end
def disable_captcha
diff --git a/spec/views/devise/shared/_signin_box.html.haml_spec.rb b/spec/views/devise/shared/_signin_box.html.haml_spec.rb
index f8867477603..dfd8c315e50 100644
--- a/spec/views/devise/shared/_signin_box.html.haml_spec.rb
+++ b/spec/views/devise/shared/_signin_box.html.haml_spec.rb
@@ -6,7 +6,7 @@ describe 'devise/shared/_signin_box' do
describe 'Crowd form' do
before do
stub_devise
- assign(:ldap_servers, [])
+ allow(view).to receive(:ldap_servers).and_return([])
allow(view).to receive(:current_application_settings).and_return(Gitlab::CurrentSettings.current_application_settings)
allow(view).to receive(:captcha_enabled?).and_return(false)
allow(view).to receive(:captcha_on_login_required?).and_return(false)
diff --git a/spec/views/help/index.html.haml_spec.rb b/spec/views/help/index.html.haml_spec.rb
index 98040da9d2c..3831ddacb72 100644
--- a/spec/views/help/index.html.haml_spec.rb
+++ b/spec/views/help/index.html.haml_spec.rb
@@ -53,6 +53,18 @@ describe 'help/index' do
end
end
+ describe 'Markdown rendering' do
+ before do
+ assign(:help_index, 'Welcome to [GitLab](https://about.gitlab.com/) Documentation.')
+ end
+
+ it 'renders Markdown' do
+ render
+
+ expect(rendered).to have_link('GitLab', href: 'https://about.gitlab.com/')
+ end
+ end
+
def stub_user(user = double)
allow(view).to receive(:user_signed_in?).and_return(user)
end
diff --git a/spec/views/help/show.html.haml_spec.rb b/spec/views/help/show.html.haml_spec.rb
new file mode 100644
index 00000000000..539c647c1d3
--- /dev/null
+++ b/spec/views/help/show.html.haml_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'help/show' do
+ describe 'Markdown rendering' do
+ before do
+ assign(:path, 'ssh/README')
+ assign(:markdown, 'Welcome to [GitLab](https://about.gitlab.com/) Documentation.')
+ end
+
+ it 'renders Markdown' do
+ render
+
+ expect(rendered).to have_link('GitLab', href: 'https://about.gitlab.com/')
+ end
+ end
+end
diff --git a/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
index aee2b0baf92..2f8a75a81c8 100644
--- a/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
@@ -58,15 +58,6 @@ describe 'layouts/nav/sidebar/_admin' do
it_behaves_like 'page has active sub tab', 'Users'
end
- context 'on logs' do
- before do
- allow(controller).to receive(:controller_name).and_return('logs')
- end
-
- it_behaves_like 'page has active tab', 'Monitoring'
- it_behaves_like 'page has active sub tab', 'Logs'
- end
-
context 'on messages' do
before do
allow(controller).to receive(:controller_name).and_return('broadcast_messages')
diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
index cd622807c09..3d5c34ae1e0 100644
--- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
@@ -136,27 +136,37 @@ describe 'layouts/nav/sidebar/_project' do
end
describe 'operations settings tab' do
- before do
- project.update!(archived: project_archived)
- end
+ describe 'archive projects' do
+ before do
+ project.update!(archived: project_archived)
+ end
- context 'when project is archived' do
- let(:project_archived) { true }
+ context 'when project is archived' do
+ let(:project_archived) { true }
- it 'does not show the operations settings tab' do
- render
+ it 'does not show the operations settings tab' do
+ render
- expect(rendered).not_to have_link('Operations', href: project_settings_operations_path(project))
+ expect(rendered).not_to have_link('Operations', href: project_settings_operations_path(project))
+ end
end
- end
- context 'when project is active' do
- let(:project_archived) { false }
+ context 'when project is active' do
+ let(:project_archived) { false }
- it 'shows the operations settings tab' do
+ it 'shows the operations settings tab' do
+ render
+
+ expect(rendered).to have_link('Operations', href: project_settings_operations_path(project))
+ end
+ end
+ end
+
+ describe 'Alert Management' do
+ it 'shows the Alerts sidebar entry' do
render
- expect(rendered).to have_link('Operations', href: project_settings_operations_path(project))
+ expect(rendered).to have_css('a[title="Alerts"]')
end
end
end
@@ -186,4 +196,30 @@ describe 'layouts/nav/sidebar/_project' do
end
end
end
+
+ describe 'project access tokens' do
+ context 'self-managed instance' do
+ before do
+ allow(Gitlab).to receive(:com?).and_return(false)
+ end
+
+ it 'displays "Access Tokens" nav item' do
+ render
+
+ expect(rendered).to have_link('Access Tokens', href: project_settings_access_tokens_path(project))
+ end
+ end
+
+ context 'gitlab.com' do
+ before do
+ allow(Gitlab).to receive(:com?).and_return(true)
+ end
+
+ it 'does not display "Access Tokens" nav item' do
+ render
+
+ expect(rendered).not_to have_link('Access Tokens', href: project_settings_access_tokens_path(project))
+ end
+ end
+ end
end
diff --git a/spec/views/profiles/show.html.haml_spec.rb b/spec/views/profiles/show.html.haml_spec.rb
index e1c21f87780..14e6feed3ab 100644
--- a/spec/views/profiles/show.html.haml_spec.rb
+++ b/spec/views/profiles/show.html.haml_spec.rb
@@ -19,48 +19,4 @@ describe 'profiles/show' do
expect(rendered).to have_field('user_id', with: user.id)
end
end
-
- context 'gitlab.com organization field' do
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
- context 'when `:gitlab_employee_badge` feature flag is enabled' do
- context 'and when user has an `@gitlab.com` email address' do
- let(:user) { create(:user, email: 'test@gitlab.com') }
-
- it 'displays the organization field as `readonly` with a `value` of `GitLab`' do
- render
-
- expect(rendered).to have_selector('#user_organization[readonly][value="GitLab"]')
- end
- end
-
- context 'and when a user does not have an `@gitlab.com` email' do
- let(:user) { create(:user, email: 'test@example.com') }
-
- it 'displays an editable organization field' do
- render
-
- expect(rendered).to have_selector('#user_organization:not([readonly]):not([value="GitLab"])')
- end
- end
- end
-
- context 'when `:gitlab_employee_badge` feature flag is disabled' do
- before do
- stub_feature_flags(gitlab_employee_badge: false)
- end
-
- context 'and when a user has an `@gitlab.com` email' do
- let(:user) { create(:user, email: 'test@gitlab.com') }
-
- it 'displays an editable organization field' do
- render
-
- expect(rendered).to have_selector('#user_organization:not([readonly]):not([value="GitLab"])')
- end
- end
- end
- end
end
diff --git a/spec/views/projects/issues/_related_branches.html.haml_spec.rb b/spec/views/projects/issues/_related_branches.html.haml_spec.rb
index a6817e3fdbf..6c9bbaea38c 100644
--- a/spec/views/projects/issues/_related_branches.html.haml_spec.rb
+++ b/spec/views/projects/issues/_related_branches.html.haml_spec.rb
@@ -5,23 +5,25 @@ require 'spec_helper'
describe 'projects/issues/_related_branches' do
include Devise::Test::ControllerHelpers
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
- let(:branch) { project.repository.find_branch('feature') }
- let!(:pipeline) { create(:ci_pipeline, project: project, sha: branch.dereferenced_target.id, ref: 'feature') }
+ let(:pipeline) { build(:ci_pipeline, :success) }
+ let(:status) { pipeline.detailed_status(build(:user)) }
before do
- assign(:project, project)
- assign(:related_branches, ['feature'])
-
- project.add_developer(user)
- allow(view).to receive(:current_user).and_return(user)
+ assign(:related_branches, [
+ { name: 'other', link: 'link-to-other', pipeline_status: nil },
+ { name: 'feature', link: 'link-to-feature', pipeline_status: status }
+ ])
render
end
- it 'shows the related branches with their build status' do
- expect(rendered).to match('feature')
+ it 'shows the related branches with their build status', :aggregate_failures do
+ expect(rendered).to have_text('feature')
+ expect(rendered).to have_text('other')
+ expect(rendered).to have_link(href: 'link-to-feature')
+ expect(rendered).to have_link(href: 'link-to-other')
expect(rendered).to have_css('.related-branch-ci-status')
+ expect(rendered).to have_css('.ci-status-icon')
+ expect(rendered).to have_css('.related-branch-info')
end
end
diff --git a/spec/views/projects/issues/show.html.haml_spec.rb b/spec/views/projects/issues/show.html.haml_spec.rb
index fb09840c8f4..60a541916e9 100644
--- a/spec/views/projects/issues/show.html.haml_spec.rb
+++ b/spec/views/projects/issues/show.html.haml_spec.rb
@@ -3,18 +3,7 @@
require 'spec_helper'
describe 'projects/issues/show' do
- let(:project) { create(:project, :repository) }
- let(:issue) { create(:issue, project: project, author: user) }
- let(:user) { create(:user) }
-
- before do
- assign(:project, project)
- assign(:issue, issue)
- assign(:noteable, issue)
- stub_template 'shared/issuable/_sidebar' => ''
- stub_template 'projects/issues/_discussion' => ''
- allow(view).to receive(:user_status).and_return('')
- end
+ include_context 'project show action'
context 'when the issue is closed' do
before do
@@ -152,18 +141,4 @@ describe 'projects/issues/show' do
expect(rendered).not_to have_selector('#js-sentry-error-stack-trace')
end
end
-
- context 'when issue is created by a GitLab team member' do
- let(:user) { create(:user, email: 'test@gitlab.com') }
-
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
- it 'renders an employee badge next to their name' do
- render
-
- expect(rendered).to have_selector('[aria-label="GitLab Team Member"]')
- end
- end
end
diff --git a/spec/views/projects/merge_requests/show.html.haml_spec.rb b/spec/views/projects/merge_requests/show.html.haml_spec.rb
index 67e7c3cf2fb..665003d137a 100644
--- a/spec/views/projects/merge_requests/show.html.haml_spec.rb
+++ b/spec/views/projects/merge_requests/show.html.haml_spec.rb
@@ -3,45 +3,7 @@
require 'spec_helper'
describe 'projects/merge_requests/show.html.haml' do
- include Devise::Test::ControllerHelpers
- include ProjectForksHelper
-
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
- let(:forked_project) { fork_project(project, user, repository: true) }
- let(:unlink_project) { Projects::UnlinkForkService.new(forked_project, user) }
- let(:note) { create(:note_on_merge_request, project: project, noteable: closed_merge_request) }
-
- let(:closed_merge_request) do
- create(:closed_merge_request,
- source_project: forked_project,
- target_project: project,
- author: user)
- end
-
- def preload_view_requirements
- # This will load the status fields of the author of the note and merge request
- # to avoid queries in when rendering the view being tested.
- closed_merge_request.author.status
- note.author.status
- end
-
- before do
- assign(:project, project)
- assign(:merge_request, closed_merge_request)
- assign(:commits_count, 0)
- assign(:note, note)
- assign(:noteable, closed_merge_request)
- assign(:notes, [])
- assign(:pipelines, Ci::Pipeline.none)
- assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, closed_merge_request))
-
- preload_view_requirements
-
- allow(view).to receive_messages(current_user: user,
- can?: true,
- current_application_settings: Gitlab::CurrentSettings.current_application_settings)
- end
+ include_context 'merge request show action'
describe 'merge request assignee sidebar' do
context 'when assignee is allowed to merge' do
@@ -92,24 +54,4 @@ describe 'projects/merge_requests/show.html.haml' do
expect(rendered).to have_css('a', visible: false, text: 'Close')
end
end
-
- context 'when merge request is created by a GitLab team member' do
- let(:user) { create(:user, email: 'test@gitlab.com') }
-
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
- it 'renders an employee badge next to their name' do
- render
-
- expect(rendered).to have_selector('[aria-label="GitLab Team Member"]')
- end
- end
-
- def serialize_issuable_sidebar(user, project, merge_request)
- MergeRequestSerializer
- .new(current_user: user, project: project)
- .represent(closed_merge_request, serializer: 'sidebar')
- end
end
diff --git a/spec/views/projects/services/_form.haml_spec.rb b/spec/views/projects/services/_form.haml_spec.rb
index 272ac97604a..a3faa92b50e 100644
--- a/spec/views/projects/services/_form.haml_spec.rb
+++ b/spec/views/projects/services/_form.haml_spec.rb
@@ -7,6 +7,8 @@ describe 'projects/services/_form' do
let(:user) { create(:admin) }
before do
+ stub_feature_flags(integration_form_refactor: false)
+
assign(:project, project)
allow(controller).to receive(:current_user).and_return(user)
@@ -29,20 +31,5 @@ describe 'projects/services/_form' do
expect(rendered).to have_content('Event will be triggered when a commit is created/updated')
expect(rendered).to have_content('Event will be triggered when a merge request is created/updated/merged')
end
-
- context 'when service is Jira' do
- let(:project) { create(:jira_project) }
-
- before do
- assign(:service, project.jira_service)
- end
-
- it 'display merge_request_events and commit_events descriptions' do
- render
-
- expect(rendered).to have_content('Jira comments will be created when an issue gets referenced in a commit.')
- expect(rendered).to have_content('Jira comments will be created when an issue gets referenced in a merge request.')
- end
- end
end
end
diff --git a/spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb b/spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb
index 94a85486cfa..d25860ab301 100644
--- a/spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb
+++ b/spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb
@@ -13,7 +13,7 @@ describe 'projects/settings/ci_cd/_autodevops_form' do
it 'shows a warning message about Kubernetes cluster' do
render
- expect(rendered).to have_text('You must add a Kubernetes cluster integration to this project with a domain in order for your deployment strategy to work correctly.')
+ expect(rendered).to have_text('Add a Kubernetes cluster integration with a domain or create an AUTO_DEVOPS_PLATFORM_TARGET CI variable')
end
context 'when the project has an available kubernetes cluster' do
diff --git a/spec/workers/authorized_project_update/project_create_worker_spec.rb b/spec/workers/authorized_project_update/project_create_worker_spec.rb
new file mode 100644
index 00000000000..5ebfb60bc79
--- /dev/null
+++ b/spec/workers/authorized_project_update/project_create_worker_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe AuthorizedProjectUpdate::ProjectCreateWorker do
+ let_it_be(:group) { create(:group, :private) }
+ let_it_be(:group_project) { create(:project, group: group) }
+ let_it_be(:group_user) { create(:user) }
+
+ let(:access_level) { Gitlab::Access::MAINTAINER }
+
+ subject(:worker) { described_class.new }
+
+ it 'calls AuthorizedProjectUpdate::ProjectCreateService' do
+ expect_next_instance_of(AuthorizedProjectUpdate::ProjectCreateService) do |service|
+ expect(service).to(receive(:execute))
+ end
+
+ worker.perform(group_project.id)
+ end
+
+ it 'returns ServiceResponse.success' do
+ result = worker.perform(group_project.id)
+
+ expect(result.success?).to be_truthy
+ end
+
+ context 'idempotence' do
+ before do
+ create(:group_member, access_level: Gitlab::Access::MAINTAINER, group: group, user: group_user)
+ ProjectAuthorization.delete_all
+ end
+
+ include_examples 'an idempotent worker' do
+ let(:job_args) { group_project.id }
+
+ it 'creates project authorization' do
+ subject
+
+ project_authorization = ProjectAuthorization.where(
+ project_id: group_project.id,
+ user_id: group_user.id,
+ access_level: access_level)
+
+ expect(project_authorization).to exist
+ expect(ProjectAuthorization.count).to eq(1)
+ end
+ end
+ end
+end
diff --git a/spec/workers/authorized_project_update/user_refresh_with_low_urgency_worker_spec.rb b/spec/workers/authorized_project_update/user_refresh_with_low_urgency_worker_spec.rb
new file mode 100644
index 00000000000..fa029dae0fa
--- /dev/null
+++ b/spec/workers/authorized_project_update/user_refresh_with_low_urgency_worker_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker do
+ it 'is labeled as low urgency' do
+ expect(described_class.get_urgency).to eq(:low)
+ end
+
+ it_behaves_like "refreshes user's project authorizations"
+end
diff --git a/spec/workers/authorized_projects_worker_spec.rb b/spec/workers/authorized_projects_worker_spec.rb
index 8ce0d4edd4f..93f22471c56 100644
--- a/spec/workers/authorized_projects_worker_spec.rb
+++ b/spec/workers/authorized_projects_worker_spec.rb
@@ -3,40 +3,5 @@
require 'spec_helper'
describe AuthorizedProjectsWorker do
- describe '#perform' do
- let(:user) { create(:user) }
-
- subject(:job) { described_class.new }
-
- it "refreshes user's authorized projects" do
- expect_any_instance_of(User).to receive(:refresh_authorized_projects)
-
- job.perform(user.id)
- end
-
- context "when the user is not found" do
- it "does nothing" do
- expect_any_instance_of(User).not_to receive(:refresh_authorized_projects)
-
- job.perform(-1)
- end
- end
-
- it_behaves_like "an idempotent worker" do
- let(:job_args) { user.id }
-
- it "does not change authorizations when run twice" do
- group = create(:group)
- create(:project, namespace: group)
- group.add_developer(user)
-
- # Delete the authorization created by the after save hook of the member
- # created above.
- user.project_authorizations.delete_all
-
- expect { job.perform(user.id) }.to change { user.project_authorizations.reload.size }.by(1)
- expect { job.perform(user.id) }.not_to change { user.project_authorizations.reload.size }
- end
- end
- end
+ it_behaves_like "refreshes user's project authorizations"
end
diff --git a/spec/workers/ci/daily_build_group_report_results_worker_spec.rb b/spec/workers/ci/daily_build_group_report_results_worker_spec.rb
new file mode 100644
index 00000000000..d9706982a62
--- /dev/null
+++ b/spec/workers/ci/daily_build_group_report_results_worker_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::DailyBuildGroupReportResultsWorker do
+ describe '#perform' do
+ let!(:pipeline) { create(:ci_pipeline) }
+
+ subject { described_class.new.perform(pipeline_id) }
+
+ context 'when pipeline is found' do
+ let(:pipeline_id) { pipeline.id }
+
+ it 'executes service' do
+ expect_any_instance_of(Ci::DailyBuildGroupReportResultService)
+ .to receive(:execute).with(pipeline)
+
+ subject
+ end
+ end
+
+ context 'when pipeline is not found' do
+ let(:pipeline_id) { 123 }
+
+ it 'does not execute service' do
+ expect_any_instance_of(Ci::DailyBuildGroupReportResultService)
+ .not_to receive(:execute)
+
+ expect { subject }
+ .not_to raise_error
+ end
+ end
+ end
+end
diff --git a/spec/workers/ci/daily_report_results_worker_spec.rb b/spec/workers/ci/daily_report_results_worker_spec.rb
deleted file mode 100644
index b6543b32b09..00000000000
--- a/spec/workers/ci/daily_report_results_worker_spec.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe Ci::DailyReportResultsWorker do
- describe '#perform' do
- let!(:pipeline) { create(:ci_pipeline) }
-
- subject { described_class.new.perform(pipeline_id) }
-
- context 'when pipeline is found' do
- let(:pipeline_id) { pipeline.id }
-
- it 'executes service' do
- expect_any_instance_of(Ci::DailyReportResultService)
- .to receive(:execute).with(pipeline)
-
- subject
- end
- end
-
- context 'when pipeline is not found' do
- let(:pipeline_id) { 123 }
-
- it 'does not execute service' do
- expect_any_instance_of(Ci::DailyReportResultService)
- .not_to receive(:execute)
-
- expect { subject }
- .not_to raise_error
- end
- end
- end
-end
diff --git a/spec/workers/concerns/application_worker_spec.rb b/spec/workers/concerns/application_worker_spec.rb
index 2fbaaf1131f..ae311a54cd1 100644
--- a/spec/workers/concerns/application_worker_spec.rb
+++ b/spec/workers/concerns/application_worker_spec.rb
@@ -21,6 +21,21 @@ describe ApplicationWorker do
end
end
+ describe '#logging_extras' do
+ it 'returns extra data to be logged that was set from #log_extra_metadata_on_done' do
+ instance.log_extra_metadata_on_done(:key1, "value1")
+ instance.log_extra_metadata_on_done(:key2, "value2")
+
+ expect(instance.logging_extras).to eq({ 'extra.gitlab_foo_bar_dummy_worker.key1' => "value1", 'extra.gitlab_foo_bar_dummy_worker.key2' => "value2" })
+ end
+
+ context 'when nothing is set' do
+ it 'returns {}' do
+ expect(instance.logging_extras).to eq({})
+ end
+ end
+ end
+
describe '#structured_payload' do
let(:payload) { {} }
diff --git a/spec/workers/create_commit_signature_worker_spec.rb b/spec/workers/create_commit_signature_worker_spec.rb
index f40482f2361..fd5d99b3265 100644
--- a/spec/workers/create_commit_signature_worker_spec.rb
+++ b/spec/workers/create_commit_signature_worker_spec.rb
@@ -17,6 +17,25 @@ describe CreateCommitSignatureWorker do
subject { described_class.new.perform(commit_shas, project.id) }
context 'when a signature is found' do
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [commit_shas, project.id] }
+
+ before do
+ # Removing the stub which can cause bugs for multiple calls to
+ # Project#commits_by.
+ allow(project).to receive(:commits_by).and_call_original
+
+ # Making sure it still goes through all the perform execution.
+ allow_next_instance_of(::Commit) do |commit|
+ allow(commit).to receive(:signature_type).and_return(:PGP)
+ end
+
+ allow_next_instance_of(::Gitlab::Gpg::Commit) do |gpg|
+ expect(gpg).to receive(:signature).once.and_call_original
+ end
+ end
+ end
+
it 'calls Gitlab::Gpg::Commit#signature' do
commits.each do |commit|
allow(commit).to receive(:signature_type).and_return(:PGP)
diff --git a/spec/workers/design_management/new_version_worker_spec.rb b/spec/workers/design_management/new_version_worker_spec.rb
new file mode 100644
index 00000000000..ef7cd8de108
--- /dev/null
+++ b/spec/workers/design_management/new_version_worker_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::NewVersionWorker do
+ describe '#perform' do
+ let(:worker) { described_class.new }
+
+ context 'the id is wrong or out-of-date' do
+ let(:version_id) { -1 }
+
+ it 'does not create system notes' do
+ expect(SystemNoteService).not_to receive(:design_version_added)
+
+ worker.perform(version_id)
+ end
+
+ it 'does not invoke GenerateImageVersionsService' do
+ expect(DesignManagement::GenerateImageVersionsService).not_to receive(:new)
+
+ worker.perform(version_id)
+ end
+
+ it 'logs the reason for this failure' do
+ expect(Sidekiq.logger).to receive(:warn)
+ .with(an_instance_of(ActiveRecord::RecordNotFound))
+
+ worker.perform(version_id)
+ end
+ end
+
+ context 'the version id is valid' do
+ let_it_be(:version) { create(:design_version, :with_lfs_file, designs_count: 2) }
+
+ it 'creates a system note' do
+ expect { worker.perform(version.id) }.to change { Note.system.count }.by(1)
+ end
+
+ it 'invokes GenerateImageVersionsService' do
+ expect_next_instance_of(DesignManagement::GenerateImageVersionsService) do |service|
+ expect(service).to receive(:execute)
+ end
+
+ worker.perform(version.id)
+ end
+
+ it 'does not log anything' do
+ expect(Sidekiq.logger).not_to receive(:warn)
+
+ worker.perform(version.id)
+ end
+ end
+
+ context 'the version includes multiple types of action' do
+ let_it_be(:version) do
+ create(:design_version, :with_lfs_file,
+ created_designs: create_list(:design, 1, :with_lfs_file),
+ modified_designs: create_list(:design, 1))
+ end
+
+ it 'creates two system notes' do
+ expect { worker.perform(version.id) }.to change { Note.system.count }.by(2)
+ end
+
+ it 'calls design_version_added' do
+ expect(SystemNoteService).to receive(:design_version_added).with(version)
+
+ worker.perform(version.id)
+ end
+ end
+ end
+end
diff --git a/spec/workers/external_service_reactive_caching_worker_spec.rb b/spec/workers/external_service_reactive_caching_worker_spec.rb
new file mode 100644
index 00000000000..45cce71b75b
--- /dev/null
+++ b/spec/workers/external_service_reactive_caching_worker_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ExternalServiceReactiveCachingWorker do
+ it_behaves_like 'reactive cacheable worker'
+end
diff --git a/spec/workers/gitlab/jira_import/import_issue_worker_spec.rb b/spec/workers/gitlab/jira_import/import_issue_worker_spec.rb
index 80629cb875e..2de609761e2 100644
--- a/spec/workers/gitlab/jira_import/import_issue_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/import_issue_worker_spec.rb
@@ -5,6 +5,8 @@ require 'spec_helper'
describe Gitlab::JiraImport::ImportIssueWorker do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
+ let_it_be(:jira_issue_label_1) { create(:label, project: project) }
+ let_it_be(:jira_issue_label_2) { create(:label, project: project) }
let(:some_key) { 'some-key' }
describe 'modules' do
@@ -17,7 +19,13 @@ describe Gitlab::JiraImport::ImportIssueWorker do
subject { described_class.new }
describe '#perform', :clean_gitlab_redis_cache do
- let(:issue_attrs) { build(:issue, project_id: project.id).as_json.compact }
+ let(:assignee_ids) { [user.id] }
+ let(:issue_attrs) do
+ build(:issue, project_id: project.id, title: 'jira issue')
+ .as_json.merge(
+ 'label_ids' => [jira_issue_label_1.id, jira_issue_label_2.id], 'assignee_ids' => assignee_ids
+ ).compact
+ end
context 'when any exception raised while inserting to DB' do
before do
@@ -47,14 +55,39 @@ describe Gitlab::JiraImport::ImportIssueWorker do
context 'when import label exists' do
before do
Gitlab::JiraImport.cache_import_label_id(project.id, label.id)
- end
- it 'does not record import failure' do
subject.perform(project.id, 123, issue_attrs, some_key)
+ end
+ it 'does not record import failure' do
expect(label.issues.count).to eq(1)
expect(Gitlab::Cache::Import::Caching.read(Gitlab::JiraImport.failed_issues_counter_cache_key(project.id)).to_i).to eq(0)
end
+
+ it 'creates an issue with the correct attributes' do
+ issue = Issue.last
+
+ expect(issue.title).to eq('jira issue')
+ expect(issue.project).to eq(project)
+ expect(issue.labels).to match_array([label, jira_issue_label_1, jira_issue_label_2])
+ expect(issue.assignees).to eq([user])
+ end
+
+ context 'when assignee_ids is nil' do
+ let(:assignee_ids) { nil }
+
+ it 'creates an issue without assignee' do
+ expect(Issue.last.assignees).to be_empty
+ end
+ end
+
+ context 'when assignee_ids is an empty array' do
+ let(:assignee_ids) { [] }
+
+ it 'creates an issue without assignee' do
+ expect(Issue.last.assignees).to be_empty
+ end
+ end
end
end
end
diff --git a/spec/workers/gitlab/jira_import/stage/finish_import_worker_spec.rb b/spec/workers/gitlab/jira_import/stage/finish_import_worker_spec.rb
index 5c3c7dcccc1..4cb6f5e28b8 100644
--- a/spec/workers/gitlab/jira_import/stage/finish_import_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/stage/finish_import_worker_spec.rb
@@ -16,7 +16,7 @@ describe Gitlab::JiraImport::Stage::FinishImportWorker do
stub_feature_flags(jira_issue_import: false)
end
- it_behaves_like 'cannot do jira import'
+ it_behaves_like 'cannot do Jira import'
end
context 'when feature flag enabled' do
@@ -27,7 +27,7 @@ describe Gitlab::JiraImport::Stage::FinishImportWorker do
end
context 'when import did not start' do
- it_behaves_like 'cannot do jira import'
+ it_behaves_like 'cannot do Jira import'
end
context 'when import started' do
diff --git a/spec/workers/gitlab/jira_import/stage/import_attachments_worker_spec.rb b/spec/workers/gitlab/jira_import/stage/import_attachments_worker_spec.rb
index 478cb447dc5..e6d41ae8bb4 100644
--- a/spec/workers/gitlab/jira_import/stage/import_attachments_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/stage/import_attachments_worker_spec.rb
@@ -15,7 +15,7 @@ describe Gitlab::JiraImport::Stage::ImportAttachmentsWorker do
stub_feature_flags(jira_issue_import: false)
end
- it_behaves_like 'cannot do jira import'
+ it_behaves_like 'cannot do Jira import'
it_behaves_like 'does not advance to next stage'
end
@@ -27,7 +27,7 @@ describe Gitlab::JiraImport::Stage::ImportAttachmentsWorker do
end
context 'when import did not start' do
- it_behaves_like 'cannot do jira import'
+ it_behaves_like 'cannot do Jira import'
it_behaves_like 'does not advance to next stage'
end
diff --git a/spec/workers/gitlab/jira_import/stage/import_issues_worker_spec.rb b/spec/workers/gitlab/jira_import/stage/import_issues_worker_spec.rb
index 6470a293461..f2067522af4 100644
--- a/spec/workers/gitlab/jira_import/stage/import_issues_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/stage/import_issues_worker_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
describe Gitlab::JiraImport::Stage::ImportIssuesWorker do
+ include JiraServiceHelper
+
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, import_type: 'jira') }
@@ -16,7 +18,7 @@ describe Gitlab::JiraImport::Stage::ImportIssuesWorker do
stub_feature_flags(jira_issue_import: false)
end
- it_behaves_like 'cannot do jira import'
+ it_behaves_like 'cannot do Jira import'
it_behaves_like 'does not advance to next stage'
end
@@ -25,10 +27,11 @@ describe Gitlab::JiraImport::Stage::ImportIssuesWorker do
before do
stub_feature_flags(jira_issue_import: true)
+ stub_jira_service_test
end
context 'when import did not start' do
- it_behaves_like 'cannot do jira import'
+ it_behaves_like 'cannot do Jira import'
it_behaves_like 'does not advance to next stage'
end
diff --git a/spec/workers/gitlab/jira_import/stage/import_labels_worker_spec.rb b/spec/workers/gitlab/jira_import/stage/import_labels_worker_spec.rb
index f1562395546..7f289de5422 100644
--- a/spec/workers/gitlab/jira_import/stage/import_labels_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/stage/import_labels_worker_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
describe Gitlab::JiraImport::Stage::ImportLabelsWorker do
+ include JiraServiceHelper
+
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, import_type: 'jira') }
@@ -16,7 +18,7 @@ describe Gitlab::JiraImport::Stage::ImportLabelsWorker do
stub_feature_flags(jira_issue_import: false)
end
- it_behaves_like 'cannot do jira import'
+ it_behaves_like 'cannot do Jira import'
it_behaves_like 'does not advance to next stage'
end
@@ -28,7 +30,7 @@ describe Gitlab::JiraImport::Stage::ImportLabelsWorker do
end
context 'when import did not start' do
- it_behaves_like 'cannot do jira import'
+ it_behaves_like 'cannot do Jira import'
it_behaves_like 'does not advance to next stage'
end
@@ -36,7 +38,12 @@ describe Gitlab::JiraImport::Stage::ImportLabelsWorker do
let!(:jira_service) { create(:jira_service, project: project) }
before do
+ stub_jira_service_test
+
jira_import.start!
+
+ WebMock.stub_request(:get, 'https://jira.example.com/rest/api/2/label?maxResults=500&startAt=0')
+ .to_return(body: {}.to_json )
end
it_behaves_like 'advance to next stage', :issues
diff --git a/spec/workers/gitlab/jira_import/stage/import_notes_worker_spec.rb b/spec/workers/gitlab/jira_import/stage/import_notes_worker_spec.rb
index 956898c1abc..f9bdbd669d8 100644
--- a/spec/workers/gitlab/jira_import/stage/import_notes_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/stage/import_notes_worker_spec.rb
@@ -15,7 +15,7 @@ describe Gitlab::JiraImport::Stage::ImportNotesWorker do
stub_feature_flags(jira_issue_import: false)
end
- it_behaves_like 'cannot do jira import'
+ it_behaves_like 'cannot do Jira import'
it_behaves_like 'does not advance to next stage'
end
@@ -27,7 +27,7 @@ describe Gitlab::JiraImport::Stage::ImportNotesWorker do
end
context 'when import did not start' do
- it_behaves_like 'cannot do jira import'
+ it_behaves_like 'cannot do Jira import'
it_behaves_like 'does not advance to next stage'
end
diff --git a/spec/workers/group_import_worker_spec.rb b/spec/workers/group_import_worker_spec.rb
index 641aa45c9b0..bb7dc116a08 100644
--- a/spec/workers/group_import_worker_spec.rb
+++ b/spec/workers/group_import_worker_spec.rb
@@ -8,13 +8,52 @@ describe GroupImportWorker do
subject { described_class.new }
+ before do
+ allow_next_instance_of(described_class) do |job|
+ allow(job).to receive(:jid).and_return(SecureRandom.hex(8))
+ end
+ end
+
describe '#perform' do
context 'when it succeeds' do
- it 'calls the ImportService' do
- expect_any_instance_of(::Groups::ImportExport::ImportService).to receive(:execute)
+ before do
+ expect_next_instance_of(::Groups::ImportExport::ImportService) do |service|
+ expect(service).to receive(:execute)
+ end
+ end
+ it 'calls the ImportService' do
subject.perform(user.id, group.id)
end
+
+ context 'import state' do
+ it 'creates group import' do
+ expect(group.import_state).to be_nil
+
+ subject.perform(user.id, group.id)
+ import_state = group.reload.import_state
+
+ expect(import_state).to be_instance_of(GroupImportState)
+ expect(import_state.status_name).to eq(:finished)
+ expect(import_state.jid).not_to be_empty
+ end
+
+ it 'sets the group import status to started' do
+ expect_next_instance_of(GroupImportState) do |import|
+ expect(import).to receive(:start!).and_call_original
+ end
+
+ subject.perform(user.id, group.id)
+ end
+
+ it 'sets the group import status to finished' do
+ expect_next_instance_of(GroupImportState) do |import|
+ expect(import).to receive(:finish!).and_call_original
+ end
+
+ subject.perform(user.id, group.id)
+ end
+ end
end
context 'when it fails' do
@@ -24,6 +63,22 @@ describe GroupImportWorker do
expect { subject.perform(non_existing_record_id, group.id) }.to raise_exception(ActiveRecord::RecordNotFound)
expect { subject.perform(user.id, non_existing_record_id) }.to raise_exception(ActiveRecord::RecordNotFound)
end
+
+ context 'import state' do
+ before do
+ expect_next_instance_of(::Groups::ImportExport::ImportService) do |service|
+ expect(service).to receive(:execute).and_raise(Gitlab::ImportExport::Error)
+ end
+ end
+
+ it 'sets the group import status to failed' do
+ expect_next_instance_of(GroupImportState) do |import|
+ expect(import).to receive(:fail_op).and_call_original
+ end
+
+ expect { subject.perform(user.id, group.id) }.to raise_exception(Gitlab::ImportExport::Error)
+ end
+ end
end
end
end
diff --git a/spec/workers/incident_management/process_alert_worker_spec.rb b/spec/workers/incident_management/process_alert_worker_spec.rb
index 9f40833dfd7..938e72aa0f0 100644
--- a/spec/workers/incident_management/process_alert_worker_spec.rb
+++ b/spec/workers/incident_management/process_alert_worker_spec.rb
@@ -6,16 +6,24 @@ describe IncidentManagement::ProcessAlertWorker do
let_it_be(:project) { create(:project) }
describe '#perform' do
- let(:alert) { :alert }
- let(:create_issue_service) { spy(:create_issue_service) }
+ let(:alert_management_alert_id) { nil }
+ let(:alert_payload) { { alert: 'payload' } }
+ let(:new_issue) { create(:issue, project: project) }
+ let(:create_issue_service) { instance_double(IncidentManagement::CreateIssueService, execute: new_issue) }
- subject { described_class.new.perform(project.id, alert) }
+ subject { described_class.new.perform(project.id, alert_payload, alert_management_alert_id) }
+
+ before do
+ allow(IncidentManagement::CreateIssueService)
+ .to receive(:new).with(project, alert_payload)
+ .and_return(create_issue_service)
+ end
it 'calls create issue service' do
expect(Project).to receive(:find_by_id).and_call_original
expect(IncidentManagement::CreateIssueService)
- .to receive(:new).with(project, :alert)
+ .to receive(:new).with(project, alert_payload)
.and_return(create_issue_service)
expect(create_issue_service).to receive(:execute)
@@ -26,7 +34,7 @@ describe IncidentManagement::ProcessAlertWorker do
context 'with invalid project' do
let(:invalid_project_id) { 0 }
- subject { described_class.new.perform(invalid_project_id, alert) }
+ subject { described_class.new.perform(invalid_project_id, alert_payload) }
it 'does not create issues' do
expect(Project).to receive(:find_by_id).and_call_original
@@ -35,5 +43,54 @@ describe IncidentManagement::ProcessAlertWorker do
subject
end
end
+
+ context 'when alert_management_alert_id is present' do
+ let!(:alert) { create(:alert_management_alert, project: project) }
+ let(:alert_management_alert_id) { alert.id }
+
+ before do
+ allow(AlertManagement::Alert)
+ .to receive(:find_by_id)
+ .with(alert_management_alert_id)
+ .and_return(alert)
+
+ allow(Gitlab::AppLogger).to receive(:warn).and_call_original
+ end
+
+ context 'when alert can be updated' do
+ it 'updates AlertManagement::Alert#issue_id' do
+ expect { subject }.to change { alert.reload.issue_id }.to(new_issue.id)
+ end
+
+ it 'does not write a warning to log' do
+ subject
+
+ expect(Gitlab::AppLogger).not_to have_received(:warn)
+ end
+ end
+
+ context 'when alert cannot be updated' do
+ before do
+ # invalidate alert
+ too_many_hosts = Array.new(AlertManagement::Alert::HOSTS_MAX_LENGTH + 1) { |_| 'host' }
+ alert.update_columns(hosts: too_many_hosts)
+ end
+
+ it 'updates AlertManagement::Alert#issue_id' do
+ expect { subject }.not_to change { alert.reload.issue_id }
+ end
+
+ it 'writes a worning to log' do
+ subject
+
+ expect(Gitlab::AppLogger).to have_received(:warn).with(
+ message: 'Cannot link an Issue with Alert',
+ issue_id: new_issue.id,
+ alert_id: alert_management_alert_id,
+ alert_errors: { hosts: ['hosts array is over 255 chars'] }
+ )
+ end
+ end
+ end
end
end
diff --git a/spec/workers/merge_request_mergeability_check_worker_spec.rb b/spec/workers/merge_request_mergeability_check_worker_spec.rb
index 2331664215f..8909af1f685 100644
--- a/spec/workers/merge_request_mergeability_check_worker_spec.rb
+++ b/spec/workers/merge_request_mergeability_check_worker_spec.rb
@@ -25,5 +25,16 @@ describe MergeRequestMergeabilityCheckWorker do
subject.perform(merge_request.id)
end
end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:merge_request) { create(:merge_request) }
+ let(:job_args) { [merge_request.id] }
+
+ it 'is mergeable' do
+ subject
+
+ expect(merge_request).to be_mergeable
+ end
+ end
end
end
diff --git a/spec/workers/namespaceless_project_destroy_worker_spec.rb b/spec/workers/namespaceless_project_destroy_worker_spec.rb
index b97a44c714d..ceea7c8d8f5 100644
--- a/spec/workers/namespaceless_project_destroy_worker_spec.rb
+++ b/spec/workers/namespaceless_project_destroy_worker_spec.rb
@@ -79,19 +79,5 @@ describe NamespacelessProjectDestroyWorker do
end
end
end
-
- context 'project has non-existing namespace' do
- let!(:project) do
- project = build(:project, namespace_id: non_existing_record_id)
- project.save(validate: false)
- project
- end
-
- it 'deletes the project' do
- subject.perform(project.id)
-
- expect(Project.unscoped.all).not_to include(project)
- end
- end
end
end
diff --git a/spec/workers/new_release_worker_spec.rb b/spec/workers/new_release_worker_spec.rb
index 9d8c5bbf919..de4e1bac48f 100644
--- a/spec/workers/new_release_worker_spec.rb
+++ b/spec/workers/new_release_worker_spec.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+# TODO: Worker can be removed in 13.2:
+# https://gitlab.com/gitlab-org/gitlab/-/issues/218231
require 'spec_helper'
describe NewReleaseWorker do
diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb
index 3d24b5f753a..aab7a36189a 100644
--- a/spec/workers/post_receive_spec.rb
+++ b/spec/workers/post_receive_spec.rb
@@ -299,6 +299,31 @@ describe PostReceive do
end
end
+ context "master" do
+ let(:default_branch) { 'master' }
+ let(:oldrev) { '012345' }
+ let(:newrev) { '6789ab' }
+ let(:changes) do
+ <<~EOF
+ #{oldrev} #{newrev} refs/heads/#{default_branch}
+ 123456 789012 refs/heads/tést2
+ EOF
+ end
+
+ let(:raw_repo) { double('RawRepo') }
+
+ 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
+ end
+ expect(project.wiki).to receive(:default_branch).twice.and_return(default_branch)
+ expect(project.wiki.repository).to receive(:raw).and_return(raw_repo)
+ expect(raw_repo).to receive(:raw_changes_between).once.with(oldrev, newrev).and_return([])
+
+ perform
+ end
+ end
+
context "branches" do
let(:changes) do
<<~EOF
@@ -307,6 +332,12 @@ describe PostReceive do
EOF
end
+ before do
+ allow_next_instance_of(Git::WikiPushService) do |service|
+ allow(service).to receive(:process_changes)
+ end
+ end
+
it 'expires the branches cache' do
expect(project.wiki.repository).to receive(:expire_branches_cache).once
@@ -440,4 +471,17 @@ describe PostReceive do
it_behaves_like 'snippet changes actions'
end
end
+
+ describe 'processing design changes' do
+ let(:gl_repository) { "design-#{project.id}" }
+
+ it 'does not do anything' do
+ worker = described_class.new
+
+ expect(worker).not_to receive(:process_wiki_changes)
+ expect(worker).not_to receive(:process_project_changes)
+
+ described_class.new.perform(gl_repository, key_id, base64_changes)
+ end
+ end
end
diff --git a/spec/workers/process_commit_worker_spec.rb b/spec/workers/process_commit_worker_spec.rb
index 21c300af7ac..d247668ac76 100644
--- a/spec/workers/process_commit_worker_spec.rb
+++ b/spec/workers/process_commit_worker_spec.rb
@@ -22,16 +22,26 @@ describe ProcessCommitWorker do
worker.perform(project.id, -1, commit.to_hash)
end
- it 'processes the commit message' do
- expect(worker).to receive(:process_commit_message).and_call_original
+ include_examples 'an idempotent worker' do
+ subject do
+ perform_multiple([project.id, user.id, commit.to_hash], worker: worker)
+ end
- worker.perform(project.id, user.id, commit.to_hash)
- end
+ it 'processes the commit message' do
+ expect(worker).to receive(:process_commit_message)
+ .exactly(IdempotentWorkerHelper::WORKER_EXEC_TIMES)
+ .and_call_original
- it 'updates the issue metrics' do
- expect(worker).to receive(:update_issue_metrics).and_call_original
+ subject
+ end
- worker.perform(project.id, user.id, commit.to_hash)
+ it 'updates the issue metrics' do
+ expect(worker).to receive(:update_issue_metrics)
+ .exactly(IdempotentWorkerHelper::WORKER_EXEC_TIMES)
+ .and_call_original
+
+ subject
+ end
end
end
diff --git a/spec/workers/project_export_worker_spec.rb b/spec/workers/project_export_worker_spec.rb
index 373e7f32530..4c49939d34e 100644
--- a/spec/workers/project_export_worker_spec.rb
+++ b/spec/workers/project_export_worker_spec.rb
@@ -17,14 +17,18 @@ describe ProjectExportWorker do
context 'when it succeeds' do
it 'calls the ExportService' do
- expect_any_instance_of(::Projects::ImportExport::ExportService).to receive(:execute)
+ expect_next_instance_of(::Projects::ImportExport::ExportService) do |service|
+ expect(service).to receive(:execute)
+ end
subject.perform(user.id, project.id, { 'klass' => 'Gitlab::ImportExport::AfterExportStrategies::DownloadNotificationStrategy' })
end
context 'export job' do
before do
- allow_any_instance_of(::Projects::ImportExport::ExportService).to receive(:execute)
+ allow_next_instance_of(::Projects::ImportExport::ExportService) do |service|
+ allow(service).to receive(:execute)
+ end
end
it 'creates an export job record for the project' do
@@ -51,7 +55,7 @@ describe ProjectExportWorker do
context 'when it fails' do
it 'does not raise an exception when strategy is invalid' do
- expect_any_instance_of(::Projects::ImportExport::ExportService).not_to receive(:execute)
+ expect(::Projects::ImportExport::ExportService).not_to receive(:new)
expect { subject.perform(user.id, project.id, { 'klass' => 'Whatever' }) }.not_to raise_error
end
diff --git a/spec/workers/project_update_repository_storage_worker_spec.rb b/spec/workers/project_update_repository_storage_worker_spec.rb
index 57a4c2128b3..98856480b21 100644
--- a/spec/workers/project_update_repository_storage_worker_spec.rb
+++ b/spec/workers/project_update_repository_storage_worker_spec.rb
@@ -9,12 +9,40 @@ describe ProjectUpdateRepositoryStorageWorker do
subject { described_class.new }
describe "#perform" do
- it "calls the update repository storage service" do
- expect_next_instance_of(Projects::UpdateRepositoryStorageService) do |instance|
- expect(instance).to receive(:execute).with('new_storage')
+ let(:service) { double(:update_repository_storage_service) }
+
+ before do
+ allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(%w[default test_second_storage])
+ end
+
+ context 'without repository storage move' do
+ it "calls the update repository storage service" do
+ expect(Projects::UpdateRepositoryStorageService).to receive(:new).and_return(service)
+ expect(service).to receive(:execute)
+
+ expect do
+ subject.perform(project.id, 'test_second_storage')
+ end.to change(ProjectRepositoryStorageMove, :count).by(1)
+
+ storage_move = project.repository_storage_moves.last
+ expect(storage_move).to have_attributes(
+ source_storage_name: "default",
+ destination_storage_name: "test_second_storage"
+ )
end
+ end
+
+ context 'with repository storage move' do
+ let!(:repository_storage_move) { create(:project_repository_storage_move) }
- subject.perform(project.id, 'new_storage')
+ it "calls the update repository storage service" do
+ expect(Projects::UpdateRepositoryStorageService).to receive(:new).and_return(service)
+ expect(service).to receive(:execute)
+
+ expect do
+ subject.perform(nil, nil, repository_storage_move.id)
+ end.not_to change(ProjectRepositoryStorageMove, :count)
+ end
end
end
end
diff --git a/spec/workers/reactive_caching_worker_spec.rb b/spec/workers/reactive_caching_worker_spec.rb
index 603ce6160ce..dcb804a7e6e 100644
--- a/spec/workers/reactive_caching_worker_spec.rb
+++ b/spec/workers/reactive_caching_worker_spec.rb
@@ -3,47 +3,5 @@
require 'spec_helper'
describe ReactiveCachingWorker do
- describe '#perform' do
- context 'when user configured kubernetes from CI/CD > Clusters' do
- let!(:cluster) { create(:cluster, :project, :provided_by_gcp) }
- let(:project) { cluster.project }
- let!(:environment) { create(:environment, project: project) }
-
- it 'calls #exclusively_update_reactive_cache!' do
- expect_any_instance_of(Environment).to receive(:exclusively_update_reactive_cache!)
-
- described_class.new.perform("Environment", environment.id)
- end
-
- context 'when ReactiveCaching::ExceededReactiveCacheLimit is raised' do
- it 'avoids failing the job and tracks via Gitlab::ErrorTracking' do
- allow_any_instance_of(Environment).to receive(:exclusively_update_reactive_cache!)
- .and_raise(ReactiveCaching::ExceededReactiveCacheLimit)
-
- expect(Gitlab::ErrorTracking).to receive(:track_exception)
- .with(kind_of(ReactiveCaching::ExceededReactiveCacheLimit))
-
- described_class.new.perform("Environment", environment.id)
- end
- end
- end
- end
-
- describe 'worker context' do
- it 'sets the related class on the job' do
- described_class.perform_async('Environment', 1, 'other', 'argument')
-
- scheduled_job = described_class.jobs.first
-
- expect(scheduled_job).to include('meta.related_class' => 'Environment')
- end
-
- it 'sets the related class on the job when it was passed as a class' do
- described_class.perform_async(Project, 1, 'other', 'argument')
-
- scheduled_job = described_class.jobs.first
-
- expect(scheduled_job).to include('meta.related_class' => 'Project')
- end
- end
+ it_behaves_like 'reactive cacheable worker'
end
diff --git a/spec/workers/stage_update_worker_spec.rb b/spec/workers/stage_update_worker_spec.rb
index 8a57cc6bbff..dc7158cfd2f 100644
--- a/spec/workers/stage_update_worker_spec.rb
+++ b/spec/workers/stage_update_worker_spec.rb
@@ -12,6 +12,15 @@ describe StageUpdateWorker do
described_class.new.perform(stage.id)
end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [stage.id] }
+
+ it 'results in the stage getting the skipped status' do
+ expect { subject }.to change { stage.reload.status }.from('pending').to('skipped')
+ expect { subject }.not_to change { stage.reload.status }
+ end
+ end
end
context 'when stage does not exist' do
diff --git a/spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb b/spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb
index c4af829a5e2..8fe3f27c8b1 100644
--- a/spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb
+++ b/spec/workers/update_head_pipeline_for_merge_request_worker_spec.rb
@@ -4,18 +4,27 @@ require 'spec_helper'
describe UpdateHeadPipelineForMergeRequestWorker do
describe '#perform' do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
- let(:merge_request) { create(:merge_request, source_project: project) }
- let(:latest_sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+ let_it_be(:latest_sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' }
context 'when pipeline exists for the source project and branch' do
- before do
- create(:ci_empty_pipeline, project: project, ref: merge_request.source_branch, sha: latest_sha)
- end
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, project: project, ref: merge_request.source_branch, sha: latest_sha) }
it 'updates the head_pipeline_id of the merge_request' do
- expect { subject.perform(merge_request.id) }.to change { merge_request.reload.head_pipeline_id }
+ expect { subject.perform(merge_request.id) }
+ .to change { merge_request.reload.head_pipeline_id }.from(nil).to(pipeline.id)
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { merge_request.id }
+
+ it 'sets the pipeline as the head pipeline when run multiple times' do
+ subject
+
+ expect(merge_request.reload.head_pipeline_id).to eq(pipeline.id)
+ end
end
context 'when merge request sha does not equal pipeline sha' do
@@ -27,6 +36,15 @@ describe UpdateHeadPipelineForMergeRequestWorker do
expect { subject.perform(merge_request.id) }
.not_to change { merge_request.reload.head_pipeline_id }
end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { merge_request.id }
+
+ it 'does not update the head_pipeline_id when run multiple times' do
+ expect { subject }
+ .not_to change { merge_request.reload.head_pipeline_id }
+ end
+ end
end
end
@@ -35,10 +53,19 @@ describe UpdateHeadPipelineForMergeRequestWorker do
expect { subject.perform(merge_request.id) }
.not_to change { merge_request.reload.head_pipeline_id }
end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { merge_request.id }
+
+ it 'does not update the head_pipeline_id when run multiple times' do
+ expect { subject }
+ .not_to change { merge_request.reload.head_pipeline_id }
+ end
+ end
end
context 'when a merge request pipeline exists' do
- let!(:merge_request_pipeline) do
+ let_it_be(:merge_request_pipeline) do
create(:ci_pipeline,
project: project,
source: :merge_request_event,
@@ -52,6 +79,16 @@ describe UpdateHeadPipelineForMergeRequestWorker do
.from(nil).to(merge_request_pipeline.id)
end
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { merge_request.id }
+
+ it 'sets the merge request pipeline as the head pipeline when run multiple times' do
+ subject
+
+ expect(merge_request.reload.head_pipeline_id).to eq(merge_request_pipeline.id)
+ end
+ end
+
context 'when branch pipeline exists' do
let!(:branch_pipeline) do
create(:ci_pipeline, project: project, source: :push, sha: latest_sha)
@@ -62,6 +99,16 @@ describe UpdateHeadPipelineForMergeRequestWorker do
.to change { merge_request.reload.head_pipeline_id }
.from(nil).to(merge_request_pipeline.id)
end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { merge_request.id }
+
+ it 'sets the merge request pipeline as the head pipeline when run multiple times' do
+ subject
+
+ expect(merge_request.reload.head_pipeline_id).to eq(merge_request_pipeline.id)
+ end
+ end
end
end
end
diff --git a/spec/workers/update_highest_role_worker_spec.rb b/spec/workers/update_highest_role_worker_spec.rb
index 1e378a5a61e..3f377208a62 100644
--- a/spec/workers/update_highest_role_worker_spec.rb
+++ b/spec/workers/update_highest_role_worker_spec.rb
@@ -18,7 +18,6 @@ describe UpdateHighestRoleWorker, :clean_gitlab_redis_shared_state do
let(:active_attributes) do
{
state: 'active',
- ghost: false,
user_type: nil
}
end
@@ -54,7 +53,6 @@ describe UpdateHighestRoleWorker, :clean_gitlab_redis_shared_state do
where(:additional_attributes) do
[
{ state: 'blocked' },
- { ghost: true },
{ user_type: :alert_bot }
]
end
diff --git a/spec/workers/x509_issuer_crl_check_worker_spec.rb b/spec/workers/x509_issuer_crl_check_worker_spec.rb
new file mode 100644
index 00000000000..f052812b86b
--- /dev/null
+++ b/spec/workers/x509_issuer_crl_check_worker_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe X509IssuerCrlCheckWorker do
+ subject(:worker) { described_class.new }
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:x509_signed_commit) { project.commit_by(oid: '189a6c924013fc3fe40d6f1ec1dc20214183bc97') }
+ let(:revoked_x509_signed_commit) { project.commit_by(oid: 'ed775cc81e5477df30c2abba7b6fdbb5d0baadae') }
+
+ describe '#perform' do
+ context 'valid crl' do
+ before do
+ stub_request(:get, "http://ch.siemens.com/pki?ZZZZZZA6.crl")
+ .to_return(status: 200, body: File.read('spec/fixtures/x509/ZZZZZZA6.crl'), headers: {})
+ end
+
+ it 'changes certificate status for revoked certificates' do
+ revoked_x509_commit = Gitlab::X509::Commit.new(revoked_x509_signed_commit)
+ x509_commit = Gitlab::X509::Commit.new(x509_signed_commit)
+ issuer = revoked_x509_commit.signature.x509_certificate.x509_issuer
+
+ expect(issuer).to eq(x509_commit.signature.x509_certificate.x509_issuer)
+ expect(revoked_x509_commit.signature.x509_certificate.good?).to be_truthy
+ expect(x509_commit.signature.x509_certificate.good?).to be_truthy
+
+ worker.perform
+ revoked_x509_commit.signature.reload
+
+ expect(revoked_x509_commit.signature.x509_certificate.revoked?).to be_truthy
+ expect(x509_commit.signature.x509_certificate.revoked?).to be_falsey
+ end
+ end
+
+ context 'invalid crl' do
+ before do
+ stub_request(:get, "http://ch.siemens.com/pki?ZZZZZZA6.crl")
+ .to_return(status: 200, body: "trash", headers: {})
+ end
+
+ it 'does not change certificate status' do
+ revoked_x509_commit = Gitlab::X509::Commit.new(revoked_x509_signed_commit)
+
+ expect(revoked_x509_commit.signature.x509_certificate.good?).to be_truthy
+
+ worker.perform
+ revoked_x509_commit.signature.reload
+
+ expect(revoked_x509_commit.signature.x509_certificate.revoked?).to be_falsey
+ end
+ end
+
+ context 'not found crl' do
+ before do
+ stub_request(:get, "http://ch.siemens.com/pki?ZZZZZZA6.crl")
+ .to_return(status: 404, body: "not found", headers: {})
+ end
+
+ it 'does not change certificate status' do
+ revoked_x509_commit = Gitlab::X509::Commit.new(revoked_x509_signed_commit)
+
+ expect(revoked_x509_commit.signature.x509_certificate.good?).to be_truthy
+
+ worker.perform
+ revoked_x509_commit.signature.reload
+
+ expect(revoked_x509_commit.signature.x509_certificate.revoked?).to be_falsey
+ end
+ end
+
+ context 'unreachable crl' do
+ before do
+ stub_request(:get, "http://ch.siemens.com/pki?ZZZZZZA6.crl")
+ .to_raise(SocketError.new('Some HTTP error'))
+ end
+
+ it 'does not change certificate status' do
+ revoked_x509_commit = Gitlab::X509::Commit.new(revoked_x509_signed_commit)
+
+ expect(revoked_x509_commit.signature.x509_certificate.good?).to be_truthy
+
+ worker.perform
+ revoked_x509_commit.signature.reload
+
+ expect(revoked_x509_commit.signature.x509_certificate.revoked?).to be_falsey
+ end
+ end
+ end
+end